Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62a997f364 | ||
|
|
279a871a77 | ||
|
|
e08d49c3eb | ||
|
|
3c8e91f644 |
+17
-5
@@ -91,15 +91,16 @@ decision that this project's threat model does not cover
|
|||||||
|
|
||||||
## Pointing the storyteller at Ollama
|
## Pointing the storyteller at Ollama
|
||||||
|
|
||||||
The endpoint, model and (unused) API key are **runtime settings stored in the
|
The endpoint, the model and the generation parameters are **runtime settings
|
||||||
database**, not environment variables. Set them on the app's Settings page, or
|
stored in the database**, not environment variables. There is no API key field:
|
||||||
with one request:
|
M2 removed it along with the cloud providers, and Ollama does not use one. Set
|
||||||
|
them on the app's Settings page, or with one request:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X PUT http://127.0.0.1:8000/api/settings \
|
curl -X PUT http://127.0.0.1:8000/api/settings \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d '{"endpoint_url":"http://127.0.0.1:11434/v1","model":"qwen2.5:3b-instruct",
|
-d '{"endpoint_url":"http://127.0.0.1:11434/v1","model":"qwen2.5:3b-instruct",
|
||||||
"api_mode":"chat","api_key":"","max_output_tokens":200,
|
"api_mode":"chat","max_output_tokens":200,
|
||||||
"context_token_budget":4096}'
|
"context_token_budget":4096}'
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -208,7 +209,7 @@ visible from within.
|
|||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend && .venv/bin/python -m pytest tests/ -q # 638 tests
|
cd backend && .venv/bin/python -m pytest tests/ -q # 698 tests
|
||||||
cd frontend && npm run lint && npm run build
|
cd frontend && npm run lint && npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -224,6 +225,17 @@ suite as complete evidence.
|
|||||||
is lost from the union, or if a new HTTP client is added without the shared
|
is lost from the union, or if a new HTTP client is added without the shared
|
||||||
verification context.
|
verification context.
|
||||||
|
|
||||||
|
M4 added `test_save_points.py`, which fails if restoring a Save Point starts
|
||||||
|
deleting history, stops going through the active head, forks on its own, lets a
|
||||||
|
Save Point on one campaign be restored through another, or lets deleting a branch
|
||||||
|
take a Save Point with it. It also fails if listing Save Points goes back to one
|
||||||
|
query per Save Point, or starts fetching narration to render the list.
|
||||||
|
|
||||||
|
`test_process_restart.py` is the durability guard: it starts the application as a
|
||||||
|
real subprocess, kills it, and starts a second one against the same database. A
|
||||||
|
Save Point that survived only because a Python object was still alive would pass
|
||||||
|
an in-process test and fail a user's restart.
|
||||||
|
|
||||||
M2 added two more. `test_endpoint_policy.py` fails if the set of reachable
|
M2 added two more. `test_endpoint_policy.py` fails if the set of reachable
|
||||||
addresses widens, or if either place the rule is applied stops applying it —
|
addresses widens, or if either place the rule is applied stops applying it —
|
||||||
it resolves hostnames through a stub, so it tests the policy rather than
|
it resolves hostnames through a stub, so it tests the policy rather than
|
||||||
|
|||||||
@@ -67,10 +67,22 @@ 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.
|
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
|
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.
|
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: deleting one deletes
|
||||||
|
no story, and deleting a branch a Save Point is kept on is refused until you
|
||||||
|
remove the Save Point yourself, so nothing takes a named moment away behind
|
||||||
|
your back.
|
||||||
- **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:
|
- **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
|
every branch, every take, the fork points, which branches the story has left behind, the Save
|
||||||
position it is being read at — all of them chosen rather than computed, which is the rule for
|
Points and the position it is being read at — all of them chosen rather than computed, which is
|
||||||
what a bundle carries. A campaign exported after two Undos imports still undone, with its
|
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
|
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.
|
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
|
- **Single user, no accounts.** There is no sign-up, no login, no session and no API key
|
||||||
@@ -88,8 +100,9 @@ that isn't the live one starts a new branch.
|
|||||||
|
|
||||||
None yet. The inherited screenshots showed upstream's UI — a Scripts tab, Log in and Sign up,
|
None yet. The inherited screenshots showed upstream's UI — a Scripts tab, Log in and Sign up,
|
||||||
a guest banner, scripting demo scenarios — none of which this fork has since M2, so they were
|
a guest banner, scripting demo scenarios — none of which this fork has since M2, so they were
|
||||||
removed rather than left standing as a picture of a product that no longer exists. New ones
|
removed rather than left standing as a picture of a product that no longer exists. The M4
|
||||||
are taken when the browser smoke test M3 still owes is run.
|
closeout drove the real application in a real browser, so the screens exist and work; taking
|
||||||
|
presentable screenshots of them is a job for the UI pass in M8.
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
@@ -121,18 +134,23 @@ Open http://localhost:5173.
|
|||||||
|
|
||||||
## Connect a model
|
## Connect a model
|
||||||
|
|
||||||
Open **Settings** in the app and point it at a local Ollama-compatible endpoint:
|
Ollama is the inference backend v1 supports. Open **Settings** in the app and point it at one:
|
||||||
|
|
||||||
| Where the model runs | Endpoint URL | Notes |
|
| Where Ollama runs | Endpoint URL | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Ollama, same machine | `http://localhost:11434/v1` | the default, and the simplest thing that works |
|
| The same machine | `http://localhost:11434/v1` | the default, and the simplest thing that works |
|
||||||
| Ollama, a machine on your own network | `http://<host>:11434/v1` or `https://<host>/v1` | see below |
|
| A machine on your own network | `http://<host>:11434/v1` or `https://<host>/v1` | explicitly configured; see below |
|
||||||
| LM Studio, same machine | `http://localhost:1234/v1` | |
|
|
||||||
|
|
||||||
Model name, generation parameters, and (optionally) summary and embedding models for the
|
Model name, generation parameters, and (optionally) summary and embedding models for the
|
||||||
Memory Bank are configured there too. No config files and no rebuild are needed. There is no
|
Memory Bank are configured there too. No config files and no rebuild are needed. There is no
|
||||||
API key field, because there is nothing to authenticate to.
|
API key field, because there is nothing to authenticate to.
|
||||||
|
|
||||||
|
The adapter underneath speaks the OpenAI-compatible protocol, because that is what Ollama
|
||||||
|
serves. That is an implementation detail, not a promise of support for arbitrary local
|
||||||
|
servers that happen to speak the same protocol. Public and cloud inference endpoints are
|
||||||
|
prohibited outright — see `planning/DECISIONS/002-ollama-only-v1.md` and
|
||||||
|
`planning/DECISIONS/011-local-inference-endpoint-policy.md`.
|
||||||
|
|
||||||
### What the endpoint policy allows
|
### What the endpoint policy allows
|
||||||
|
|
||||||
The address is checked when you save it and again before every request. Only loopback and
|
The address is checked when you save it and again before every request. Only loopback and
|
||||||
@@ -146,7 +164,7 @@ machine serves HTTPS with a certificate from a CA you installed, it works: certi
|
|||||||
verified against your operating system's trust store as well as the bundled one. Verification
|
verified against your operating system's trust store as well as the bundled one. Verification
|
||||||
itself is never relaxed, and there is no option to turn it off.
|
itself is never relaxed, and there is no option to turn it off.
|
||||||
|
|
||||||
### Playing against a local shim
|
### Playing against a local shim (development only)
|
||||||
|
|
||||||
`backend/tools/claude_shim.py` serves an OpenAI-compatible endpoint on `127.0.0.1:8787`
|
`backend/tools/claude_shim.py` serves an OpenAI-compatible endpoint on `127.0.0.1:8787`
|
||||||
backed by a command-line tool, which is useful for testing the turn engine against a stronger
|
backed by a command-line tool, which is useful for testing the turn engine against a stronger
|
||||||
@@ -191,6 +209,7 @@ frontend/ React + Vite SPA ──HTTP/SSE──► backend/ FastAPI
|
|||||||
├─ tlstrust.py one TLS context: the OS trust store unioned with certifi's
|
├─ tlstrust.py one TLS context: the OS trust store unioned with certifi's
|
||||||
├─ tree.py forking, promotion, and where a node is placed
|
├─ 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
|
├─ 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
|
├─ attempts.py the takes of one turn, grouped by parent
|
||||||
├─ context/ prompt assembly under a token budget + lineage/history windowing
|
├─ context/ prompt assembly under a token budget + lineage/history windowing
|
||||||
├─ worldstate/ the stat engine: clamps, cooldowns, bands, milestones
|
├─ worldstate/ the stat engine: clamps, cooldowns, bands, milestones
|
||||||
@@ -205,7 +224,7 @@ development, Vite proxies `/api` to FastAPI.
|
|||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
638 backend tests: unit tests plus full HTTP integration through the real turn engine, with
|
698 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
|
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
|
rather than a convenience — an offline claim proved on a machine that has been online once
|
||||||
proves nothing.
|
proves nothing.
|
||||||
|
|||||||
@@ -134,6 +134,16 @@ def export(db: Session, adventure: models.Adventure) -> dict:
|
|||||||
"memoryCursor": _exported_anchor(adventure, cursors.MEMORY, local),
|
"memoryCursor": _exported_anchor(adventure, cursors.MEMORY, local),
|
||||||
"summaryCursor": _exported_anchor(adventure, cursors.SUMMARY, local),
|
"summaryCursor": _exported_anchor(adventure, cursors.SUMMARY, local),
|
||||||
"memories": [_exported_memory(m, local) for m in adventure.memories],
|
"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": [
|
"storyCards": [
|
||||||
{"type": c.type, "name": c.name, "keys": c.keys,
|
{"type": c.type, "name": c.name, "keys": c.keys,
|
||||||
"entry": c.entry, "notes": c.notes}
|
"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:
|
def _imported_persona(persona) -> dict:
|
||||||
"""Reads a bundle's `persona` block into `Adventure` keyword arguments.
|
"""Reads a bundle's `persona` block into `Adventure` keyword arguments.
|
||||||
|
|
||||||
@@ -280,6 +319,13 @@ def plan(bundle: dict, version: str) -> dict:
|
|||||||
"branches": branches,
|
"branches": branches,
|
||||||
"nodes": nodes,
|
"nodes": nodes,
|
||||||
"memories": _planned_memories(bundle, len(branches)),
|
"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,
|
"head": head,
|
||||||
# None means the file does not say, which is every version 1 bundle and
|
# 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
|
# 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
|
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:
|
def _planned_anchors(bundle: dict, branches: int) -> dict:
|
||||||
anchors = {}
|
anchors = {}
|
||||||
for name in ("memory", "summary"):
|
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_nodes(db, adventure, story["nodes"], ids)
|
||||||
_write_memories(db, adventure, story["memories"], ids)
|
_write_memories(db, adventure, story["memories"], ids)
|
||||||
_point_the_head(adventure, story, ids)
|
_point_the_head(adventure, story, ids)
|
||||||
|
_write_checkpoints(db, adventure, story["checkpoints"], ids)
|
||||||
_write_anchors(adventure, story, ids)
|
_write_anchors(adventure, story, ids)
|
||||||
|
|
||||||
|
|
||||||
@@ -665,6 +763,32 @@ def _write_memories(
|
|||||||
db.add(memory)
|
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(
|
def _point_the_head(
|
||||||
adventure: models.Adventure, story: dict, ids: list[int]
|
adventure: models.Adventure, story: dict, ids: list[int]
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -314,6 +314,40 @@ def move_to(db: Session, adventure: models.Adventure, depth: int) -> None:
|
|||||||
attempts.restore_state(adventure, node_at(db, adventure, depth))
|
attempts.restore_state(adventure, node_at(db, adventure, depth))
|
||||||
|
|
||||||
|
|
||||||
|
def move_to_node(db: Session, adventure: models.Adventure, node: models.Action) -> bool:
|
||||||
|
"""Moves the head onto `node`, changing line only if it is not on this one.
|
||||||
|
|
||||||
|
M4 restores a Save Point through this, and it adds no restoring of its own:
|
||||||
|
the depth half is `move_to` unchanged, so the state, the transcript, the
|
||||||
|
assembled context and memory eligibility all arrive exactly as they do for
|
||||||
|
Undo and Redo. Returns whether the line had to change as well as the depth,
|
||||||
|
which is the one thing about a restore a caller cannot work out afterwards.
|
||||||
|
|
||||||
|
The head is two values, and the two halves move for different reasons. A
|
||||||
|
Save Point almost always names a position on the story being read — its own
|
||||||
|
line, or the shared prefix that line inherits — and then only the depth
|
||||||
|
moves. Leaving the branch alone is what makes the restored position keep the
|
||||||
|
continuation it has: after a divergence, restoring to the shared prefix must
|
||||||
|
put the reader back on the *new* line, where Redo walks into the turns they
|
||||||
|
are still writing, not into the future they left. Reaching for the Save
|
||||||
|
Point's own branch there would quietly hand back the abandoned story.
|
||||||
|
|
||||||
|
The other case is real and has to work. A Save Point survives divergence
|
||||||
|
(`STORY-BRANCH-SEMANTICS.md` §19), so one can name a position on a line the
|
||||||
|
story has since left, and no amount of depth movement reaches a branch this
|
||||||
|
path does not contain. The line then moves as well — one assignment, the
|
||||||
|
same one `switch_branch` makes — and the depth still moves through
|
||||||
|
`move_to`. Nothing is created: a restore never forks, whichever case it
|
||||||
|
takes. The first write below the restored head does, through
|
||||||
|
`fork_if_behind_head`, like every other write.
|
||||||
|
"""
|
||||||
|
switched = not lineage.path_of(db, adventure).uncapped().contains(node)
|
||||||
|
if switched:
|
||||||
|
adventure.head_branch_id = node.branch_id
|
||||||
|
move_to(db, adventure, node.depth)
|
||||||
|
return switched
|
||||||
|
|
||||||
|
|
||||||
def fork_if_behind_head(db: Session, adventure: models.Adventure) -> bool:
|
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.
|
"""Gives the story a new branch when a write would displace a retained future.
|
||||||
|
|
||||||
|
|||||||
@@ -350,6 +350,17 @@ MIGRATIONS: list[tuple[int, str | dict[str, str]]] = [
|
|||||||
# No backfill.
|
# No backfill.
|
||||||
(78, "ALTER TABLE branches ADD COLUMN superseded_at TIMESTAMP"),
|
(78, "ALTER TABLE branches ADD COLUMN superseded_at TIMESTAMP"),
|
||||||
(79, "ALTER TABLE branches ADD COLUMN superseded_depth INTEGER"),
|
(79, "ALTER TABLE branches ADD COLUMN superseded_depth INTEGER"),
|
||||||
|
# M4: Save Points. `create_all` creates the `checkpoints` table itself, on
|
||||||
|
# existing databases as well as fresh ones, exactly as it did for
|
||||||
|
# `memories` at version 2 and `branches` at version 46. What it does not
|
||||||
|
# create is the index every list and every cascade reads, so that is what
|
||||||
|
# this version is.
|
||||||
|
#
|
||||||
|
# No backfill. A Save Point records a decision someone made, and nobody has
|
||||||
|
# made one yet: an M3 database has no position a user chose to name, and
|
||||||
|
# inventing one would be inventing the decision.
|
||||||
|
(80, "CREATE INDEX IF NOT EXISTS ix_checkpoints_adventure "
|
||||||
|
"ON checkpoints (adventure_id)"),
|
||||||
]
|
]
|
||||||
|
|
||||||
LATEST_VERSION = max((v for v, _ in MIGRATIONS), default=1)
|
LATEST_VERSION = max((v for v, _ in MIGRATIONS), default=1)
|
||||||
|
|||||||
@@ -241,6 +241,67 @@ class Branch(Base):
|
|||||||
superseded_depth: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
superseded_depth: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Checkpoint(Base):
|
||||||
|
"""M4: a Save Point — a durable named pointer to a story position.
|
||||||
|
|
||||||
|
"Save Point" is what the user reads; `checkpoint` is what the code calls it
|
||||||
|
(`BROWSER-UX-SPEC.md` §23).
|
||||||
|
|
||||||
|
The row holds a name and a coordinate, and no story. `DATA-MODEL.md` §8
|
||||||
|
describes the pointer as naming a turn; the coordinate here is
|
||||||
|
`(branch_id, depth)`, which is what M3 made the head and what
|
||||||
|
`head.node_at` resolves. Restoring one is therefore head movement with a
|
||||||
|
bounds check rather than a restore system of its own — see ADR 012 and
|
||||||
|
`head.move_to_node`.
|
||||||
|
|
||||||
|
A coordinate rather than an action id, deliberately. One coordinate can
|
||||||
|
hold several attempts at a turn and exactly one of them is live, so a
|
||||||
|
retry replaces the row a Save Point would have pinned. "Turn 42 of this
|
||||||
|
line" survives a retry; "action 918" would point at a take the story no
|
||||||
|
longer tells.
|
||||||
|
|
||||||
|
`branch_id` is the branch the node itself sits on, not the branch that was
|
||||||
|
being read when the Save Point was made. Those differ whenever the head is
|
||||||
|
resting in a shared prefix, and the node's own branch is the one that still
|
||||||
|
names the position after the reader has moved elsewhere.
|
||||||
|
|
||||||
|
**Nothing removes a Save Point but the user.** They are not cleaned up for
|
||||||
|
going stale, for being behind the head, or for pointing into a future the
|
||||||
|
story has left (`STORY-BRANCH-SEMANTICS.md` §19).
|
||||||
|
|
||||||
|
That includes deleting a branch. `branch_id` carries `ON DELETE CASCADE` as
|
||||||
|
referential integrity — a Save Point must never point at a branch that is
|
||||||
|
gone — but the branch endpoint refuses to delete a branch any Save Point
|
||||||
|
names, so the cascade does not fire through the application
|
||||||
|
(`routers/adventures/branches.py`, `STORY-BRANCH-SEMANTICS.md` §19.1). The
|
||||||
|
user deletes the Save Point first, which deletes no story, and then the
|
||||||
|
branch. Deleting the whole campaign does cascade, and should: that is what
|
||||||
|
the user asked for.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "checkpoints"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
adventure_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("adventures.id", ondelete="CASCADE")
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(120), default="")
|
||||||
|
# `DATA-MODEL.md` §8's optional notes, and `BROWSER-UX-SPEC.md` §24's
|
||||||
|
# optional second field. Empty is the ordinary case.
|
||||||
|
note: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
branch_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("branches.id", ondelete="CASCADE")
|
||||||
|
)
|
||||||
|
depth: Mapped[int] = mapped_column(Integer)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
|
||||||
|
# Bumped by a rename, which is the only edit a Save Point allows. The
|
||||||
|
# coordinate is never rewritten: `STORY-BRANCH-SEMANTICS.md` §24 keeps a
|
||||||
|
# Save Point's meaning auditable by making "move it" delete-and-recreate.
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, default=utcnow, onupdate=utcnow
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Memory(Base):
|
class Memory(Base):
|
||||||
"""Phase 6: an auto-summarized (or hand-written) fact about the adventure.
|
"""Phase 6: an auto-summarized (or hand-written) fact about the adventure.
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ Read the modules in this order to follow a turn from end to end:
|
|||||||
turns playing a turn, and the lock that allows only one at a time
|
turns playing a turn, and the lock that allows only one at a time
|
||||||
takes retries and the attempts that collect at one coordinate
|
takes retries and the attempts that collect at one coordinate
|
||||||
branches where a story splits
|
branches where a story splits
|
||||||
|
checkpoints Save Points: durable names for positions the head can return to
|
||||||
|
|
||||||
What this package re-exports, and what it deliberately does not:
|
What this package re-exports, and what it deliberately does not:
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ from . import ( # noqa: F401
|
|||||||
turns,
|
turns,
|
||||||
takes,
|
takes,
|
||||||
branches,
|
branches,
|
||||||
|
checkpoints,
|
||||||
bundle_io,
|
bundle_io,
|
||||||
refresh,
|
refresh,
|
||||||
insights,
|
insights,
|
||||||
|
|||||||
@@ -56,6 +56,19 @@ def list_branches(
|
|||||||
.group_by(models.Action.branch_id)
|
.group_by(models.Action.branch_id)
|
||||||
.all()
|
.all()
|
||||||
}
|
}
|
||||||
|
# M4 closeout: how many Save Points name a position on each line. Deleting a
|
||||||
|
# branch deletes them along with its story, and the panel has to be able to
|
||||||
|
# say so before the button is pressed (review §R B-2). One grouped query for
|
||||||
|
# the whole tree, like the one above it — never one per branch.
|
||||||
|
save_points = {
|
||||||
|
branch_id: count
|
||||||
|
for branch_id, count in db.query(
|
||||||
|
models.Checkpoint.branch_id, func.count(models.Checkpoint.id)
|
||||||
|
)
|
||||||
|
.filter(models.Checkpoint.adventure_id == adventure.id)
|
||||||
|
.group_by(models.Checkpoint.branch_id)
|
||||||
|
.all()
|
||||||
|
}
|
||||||
out = []
|
out = []
|
||||||
for branch in branches:
|
for branch in branches:
|
||||||
count, tip = owned.get(branch.id, (0, None))
|
count, tip = owned.get(branch.id, (0, None))
|
||||||
@@ -70,6 +83,7 @@ def list_branches(
|
|||||||
branch.fork_depth if branch.fork_depth is not None else tree.NO_DEPTH
|
branch.fork_depth if branch.fork_depth is not None else tree.NO_DEPTH
|
||||||
),
|
),
|
||||||
own_actions=count,
|
own_actions=count,
|
||||||
|
save_points=save_points.get(branch.id, 0),
|
||||||
is_head=(branch.id == adventure.head_branch_id),
|
is_head=(branch.id == adventure.head_branch_id),
|
||||||
name=branch.name,
|
name=branch.name,
|
||||||
created_at=branch.created_at,
|
created_at=branch.created_at,
|
||||||
@@ -151,15 +165,31 @@ def delete_branch(
|
|||||||
heavily retried adventure from growing without bound. That is why it ships
|
heavily retried adventure from growing without bound. That is why it ships
|
||||||
with the view that first lets anyone create a fork rather than after it.
|
with the view that first lets anyone create a fork rather than after it.
|
||||||
|
|
||||||
Two kinds of branch cannot be deleted. The root cannot, because it holds the
|
Three kinds of branch cannot be deleted. The root cannot, because it holds
|
||||||
turns every other branch borrows, so deleting it deletes the whole story. The
|
the turns every other branch borrows, so deleting it deletes the whole story.
|
||||||
branch currently being read cannot, and neither can any branch it was forked
|
The branch currently being read cannot, and neither can any branch it was
|
||||||
from, because the cascade would remove the head under the player and leave
|
forked from, because the cascade would remove the head under the player and
|
||||||
`head_branch_id` dangling. Switch branches first.
|
leave `head_branch_id` dangling. Switch branches first.
|
||||||
|
|
||||||
|
The third is M4's: **a branch a Save Point names cannot be deleted while that
|
||||||
|
Save Point exists.** `STORY-BRANCH-SEMANTICS.md` §19 says a named checkpoint
|
||||||
|
remains until explicitly deleted, and §28 says a future cleanup feature must
|
||||||
|
retain paths referenced by checkpoints. A cascade that removed Save Points
|
||||||
|
along with a branch would break both, and would break them silently: the
|
||||||
|
story the user asked to delete is the visible thing, and the named moments
|
||||||
|
would go without ever being named in the request. So the deletion is refused,
|
||||||
|
the Save Points are listed, and the user decides — delete the Save Point
|
||||||
|
first, then the branch. Deleting a Save Point still deletes no story (§25),
|
||||||
|
so the recovery costs nothing but a click.
|
||||||
|
|
||||||
|
The check covers the whole doomed subtree, not just this branch, because
|
||||||
|
deleting a branch takes everything forked from it.
|
||||||
|
|
||||||
Nodes and memories are deleted by `ON DELETE CASCADE`, and descendants by the
|
Nodes and memories are deleted by `ON DELETE CASCADE`, and descendants by the
|
||||||
cascade on `branches.parent_branch_id`, so the delete is a single statement
|
cascade on `branches.parent_branch_id`, so the delete is a single statement
|
||||||
however deep the subtree is.
|
however deep the subtree is. `checkpoints.branch_id` also carries a cascade,
|
||||||
|
as referential integrity — a Save Point must never point at a branch that is
|
||||||
|
gone — but the guard above means it does not fire through this endpoint.
|
||||||
"""
|
"""
|
||||||
branch = get_branch_or_404(adventure, branch_id, db)
|
branch = get_branch_or_404(adventure, branch_id, db)
|
||||||
if branch.parent_branch_id is None:
|
if branch.parent_branch_id is None:
|
||||||
@@ -178,6 +208,11 @@ def delete_branch(
|
|||||||
400, "You are reading this branch, or one forked from it. Switch to "
|
400, "You are reading this branch, or one forked from it. Switch to "
|
||||||
"another branch first.",
|
"another branch first.",
|
||||||
)
|
)
|
||||||
|
# Refused before the lock is taken: this is a decision about the request, not
|
||||||
|
# a race with a turn.
|
||||||
|
protecting = _save_points_protecting(db, adventure, branch)
|
||||||
|
if protecting:
|
||||||
|
raise HTTPException(409, _protected_message(protecting))
|
||||||
turns.acquire_turn_lock(adventure_id)
|
turns.acquire_turn_lock(adventure_id)
|
||||||
try:
|
try:
|
||||||
# Collect the subtree before the delete, because afterwards there is no
|
# Collect the subtree before the delete, because afterwards there is no
|
||||||
@@ -199,6 +234,55 @@ def delete_branch(
|
|||||||
finally:
|
finally:
|
||||||
turns._active_turns.discard(adventure_id)
|
turns._active_turns.discard(adventure_id)
|
||||||
|
|
||||||
|
# How many Save Point names to spell out before the message starts summarising.
|
||||||
|
# Enough to be actionable, few enough to stay a sentence.
|
||||||
|
NAMED_IN_REFUSAL = 3
|
||||||
|
|
||||||
|
|
||||||
|
def _save_points_protecting(
|
||||||
|
db: Session, adventure: models.Adventure, branch: models.Branch
|
||||||
|
) -> list[models.Checkpoint]:
|
||||||
|
"""Returns the Save Points that deleting `branch` would destroy.
|
||||||
|
|
||||||
|
The whole subtree, because deleting a branch takes everything forked from
|
||||||
|
it, and a check that looked only at this branch would let a Save Point on a
|
||||||
|
child be deleted without a word.
|
||||||
|
"""
|
||||||
|
doomed = _branch_subtree(db, adventure, branch)
|
||||||
|
return (
|
||||||
|
db.query(models.Checkpoint)
|
||||||
|
.filter(
|
||||||
|
models.Checkpoint.adventure_id == adventure.id,
|
||||||
|
models.Checkpoint.branch_id.in_(doomed),
|
||||||
|
)
|
||||||
|
.order_by(models.Checkpoint.created_at, models.Checkpoint.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _protected_message(protecting: list[models.Checkpoint]) -> str:
|
||||||
|
"""Says which Save Points stand in the way, and what to do about it.
|
||||||
|
|
||||||
|
Named rather than counted, because "2 Save Points" leaves the user hunting
|
||||||
|
for which ones. A long list is truncated so the message stays readable; the
|
||||||
|
Save Points panel shows the rest.
|
||||||
|
"""
|
||||||
|
names = [f"“{c.name}”" for c in protecting[:NAMED_IN_REFUSAL]]
|
||||||
|
listed = ", ".join(names)
|
||||||
|
extra = len(protecting) - len(names)
|
||||||
|
if extra > 0:
|
||||||
|
listed += f" and {extra} more"
|
||||||
|
subject = "a Save Point" if len(protecting) == 1 else "Save Points"
|
||||||
|
return (
|
||||||
|
f"This branch, or a branch forked from it, is where {subject} "
|
||||||
|
f"{listed} {'is' if len(protecting) == 1 else 'are'} saved. Delete "
|
||||||
|
f"{'that Save Point' if len(protecting) == 1 else 'those Save Points'} "
|
||||||
|
f"first if you no longer need "
|
||||||
|
f"{'it' if len(protecting) == 1 else 'them'}, then delete the branch. "
|
||||||
|
f"Deleting a Save Point does not delete any story."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _branch_subtree(
|
def _branch_subtree(
|
||||||
db: Session, adventure: models.Adventure, root: models.Branch
|
db: Session, adventure: models.Adventure, root: models.Branch
|
||||||
) -> set[int]:
|
) -> set[int]:
|
||||||
|
|||||||
@@ -0,0 +1,351 @@
|
|||||||
|
"""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 dataclasses import dataclass
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException
|
||||||
|
from sqlalchemy import and_, or_
|
||||||
|
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()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _Coordinate:
|
||||||
|
"""The shape `lineage.Path` reads, without loading a story row.
|
||||||
|
|
||||||
|
`Path.contains` asks three things of a node: its branch, its depth, and
|
||||||
|
whether it is live. A coordinate already known to resolve has all three, so
|
||||||
|
the membership question can be put to the coordinate itself. That keeps the
|
||||||
|
single implementation of "is this on the path being read" in `lineage`,
|
||||||
|
where M3 put it, while costing no query and no prose.
|
||||||
|
"""
|
||||||
|
|
||||||
|
branch_id: int
|
||||||
|
depth: int
|
||||||
|
live: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
def _live_coordinates(
|
||||||
|
db: Session, adventure: models.Adventure, checkpoints: list[models.Checkpoint]
|
||||||
|
) -> set[tuple[int, int]]:
|
||||||
|
"""Returns which of these Save Points' coordinates still name a live turn.
|
||||||
|
|
||||||
|
One query for the whole list, selecting two integer columns.
|
||||||
|
|
||||||
|
This replaces a resolution per Save Point (M4 review §R B-1), which cost one
|
||||||
|
query each and loaded whole `Action` entities — narration included — to
|
||||||
|
answer a question that is only ever "does a row exist here". `paging.py`
|
||||||
|
states the rule this now follows: a bulk read names the columns it needs, so
|
||||||
|
a new column costs nothing until someone adds it to the list.
|
||||||
|
|
||||||
|
The clause is an OR of exact `(branch, depth)` pairs rather than
|
||||||
|
`branch IN (…) AND depth IN (…)`, which would match the cross product and
|
||||||
|
report a Save Point as resolved because *some other* Save Point's depth
|
||||||
|
exists on *this* one's branch.
|
||||||
|
"""
|
||||||
|
coordinates = {(c.branch_id, c.depth) for c in checkpoints}
|
||||||
|
if not coordinates:
|
||||||
|
return set()
|
||||||
|
rows = (
|
||||||
|
db.query(models.Action.branch_id, models.Action.depth)
|
||||||
|
.filter(
|
||||||
|
models.Action.adventure_id == adventure.id,
|
||||||
|
models.Action.live.is_(True),
|
||||||
|
or_(*[
|
||||||
|
and_(models.Action.branch_id == branch, models.Action.depth == depth)
|
||||||
|
for branch, depth in coordinates
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return {(branch, depth) for branch, depth in rows}
|
||||||
|
|
||||||
|
|
||||||
|
def _render_all(
|
||||||
|
db: Session, adventure: models.Adventure, checkpoints: list[models.Checkpoint]
|
||||||
|
) -> list[schemas.CheckpointOut]:
|
||||||
|
"""Reads Save Points out with the three facts the panel needs about them.
|
||||||
|
|
||||||
|
Bounded work whatever the length of the list: one query for the coordinates
|
||||||
|
and one lineage for the campaign, both computed before the loop. Rendering
|
||||||
|
one Save Point and rendering fifty differ in Python, not in round trips.
|
||||||
|
"""
|
||||||
|
live = _live_coordinates(db, adventure, checkpoints)
|
||||||
|
# The path is a property of the campaign, not of any Save Point, so it is
|
||||||
|
# read once. Reading it per row was the other half of the N+1.
|
||||||
|
path = lineage.path_of(db, adventure).uncapped()
|
||||||
|
out = []
|
||||||
|
for checkpoint in checkpoints:
|
||||||
|
coordinate = (checkpoint.branch_id, checkpoint.depth)
|
||||||
|
resolved = coordinate in live
|
||||||
|
rendered = schemas.CheckpointOut.model_validate(checkpoint)
|
||||||
|
# The same `depth + 1` the branch list counts with, so a moment number
|
||||||
|
# means the same thing in both places.
|
||||||
|
rendered.turn = checkpoint.depth + 1
|
||||||
|
rendered.resolved = resolved
|
||||||
|
rendered.on_path = resolved and path.contains(
|
||||||
|
_Coordinate(checkpoint.branch_id, checkpoint.depth)
|
||||||
|
)
|
||||||
|
out.append(rendered)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _rendered(
|
||||||
|
db: Session, adventure: models.Adventure, checkpoint: models.Checkpoint
|
||||||
|
) -> schemas.CheckpointOut:
|
||||||
|
"""Reads one Save Point out, through the same path the list uses."""
|
||||||
|
return _render_all(db, adventure, [checkpoint])[0]
|
||||||
|
|
||||||
|
|
||||||
|
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 _render_all(db, adventure, rows)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{adventure_id}/checkpoints",
|
||||||
|
response_model=schemas.CheckpointOut,
|
||||||
|
status_code=201,
|
||||||
|
)
|
||||||
|
def create_checkpoint(
|
||||||
|
adventure_id: int,
|
||||||
|
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.
|
||||||
|
|
||||||
|
**Held under the campaign's turn lock** (M4 closeout, review §S C-5). "Save
|
||||||
|
where I am" has to name one committed position, and the head is exactly what
|
||||||
|
a turn in flight is about to move. Without the lock this endpoint could read
|
||||||
|
`head_depth` while a turn was mid-commit and store a coordinate for a
|
||||||
|
position the story had already left — a Save Point silently naming the wrong
|
||||||
|
moment, which no later operation could detect. It is the same lock Undo,
|
||||||
|
Redo and Restore take, for the same reason, and not a new mechanism.
|
||||||
|
|
||||||
|
Rename and Delete deliberately do **not** take it: neither reads nor moves a
|
||||||
|
story position, so there is nothing for a turn in flight to race them over.
|
||||||
|
"""
|
||||||
|
name = _clean_name(payload.name)
|
||||||
|
turns.acquire_turn_lock(adventure_id)
|
||||||
|
try:
|
||||||
|
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)
|
||||||
|
finally:
|
||||||
|
turns._active_turns.discard(adventure_id)
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
||||||
@@ -25,6 +25,10 @@ ICON_MAX = 16 # One emoji or glyph. VARCHAR(16).
|
|||||||
BRANCH_NAME_MAX = 80 # What a player called one line of the story. VARCHAR(80).
|
BRANCH_NAME_MAX = 80 # What a player called one line of the story. VARCHAR(80).
|
||||||
PERSONA_NAME_MAX = 80 # The protagonist's name. VARCHAR(80).
|
PERSONA_NAME_MAX = 80 # The protagonist's name. VARCHAR(80).
|
||||||
PERSONA_PRONOUNS_MAX = 40 # "they/them" and the like. VARCHAR(40).
|
PERSONA_PRONOUNS_MAX = 40 # "they/them" and the like. VARCHAR(40).
|
||||||
|
# M4: what a player called a Save Point. VARCHAR(120). Wider than a branch name
|
||||||
|
# because these are sentences rather than labels — "Before entering the abbey"
|
||||||
|
# is the example the specification uses throughout.
|
||||||
|
CHECKPOINT_NAME_MAX = 120
|
||||||
|
|
||||||
Name = Annotated[str, Field(max_length=NAME_MAX)]
|
Name = Annotated[str, Field(max_length=NAME_MAX)]
|
||||||
Tags = Annotated[str, Field(max_length=TAGS_MAX)]
|
Tags = Annotated[str, Field(max_length=TAGS_MAX)]
|
||||||
@@ -35,6 +39,7 @@ Image = Annotated[str, Field(max_length=IMAGE_MAX)]
|
|||||||
Icon = Annotated[str, Field(max_length=ICON_MAX)]
|
Icon = Annotated[str, Field(max_length=ICON_MAX)]
|
||||||
PersonaName = Annotated[str, Field(max_length=PERSONA_NAME_MAX)]
|
PersonaName = Annotated[str, Field(max_length=PERSONA_NAME_MAX)]
|
||||||
PersonaPronouns = Annotated[str, Field(max_length=PERSONA_PRONOUNS_MAX)]
|
PersonaPronouns = Annotated[str, Field(max_length=PERSONA_PRONOUNS_MAX)]
|
||||||
|
CheckpointName = Annotated[str, Field(max_length=CHECKPOINT_NAME_MAX)]
|
||||||
|
|
||||||
|
|
||||||
class ORMModel(BaseModel):
|
class ORMModel(BaseModel):
|
||||||
@@ -254,6 +259,10 @@ class BranchOut(ORMModel):
|
|||||||
fork_depth: int | None = None
|
fork_depth: int | None = None
|
||||||
depth: int
|
depth: int
|
||||||
own_actions: int = 0
|
own_actions: int = 0
|
||||||
|
# M4: how many Save Points name a position on this line. Deleting the branch
|
||||||
|
# deletes them with its story, so the panel warns with a number rather than
|
||||||
|
# a vague caution. Zero for a line nobody has bookmarked, which is most.
|
||||||
|
save_points: int = 0
|
||||||
is_head: bool = False
|
is_head: bool = False
|
||||||
# NULL for a branch nobody has named. The client labels those from the fork
|
# NULL for a branch nobody has named. The client labels those from the fork
|
||||||
# depth rather than the server inventing a name. See the column comment.
|
# depth rather than the server inventing a name. See the column comment.
|
||||||
@@ -267,6 +276,67 @@ class BranchRename(BaseModel):
|
|||||||
name: Annotated[str, Field(max_length=BRANCH_NAME_MAX)] | None = None
|
name: Annotated[str, Field(max_length=BRANCH_NAME_MAX)] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Save Points (M4) ----------
|
||||||
|
#
|
||||||
|
# "Save Point" is the user-facing term and `checkpoint` is the internal one
|
||||||
|
# (`BROWSER-UX-SPEC.md` §23). The wire format uses the internal name, as the
|
||||||
|
# rest of this module does.
|
||||||
|
|
||||||
|
|
||||||
|
class CheckpointOut(ORMModel):
|
||||||
|
"""One Save Point: a name and the position it names.
|
||||||
|
|
||||||
|
The position is reported three ways because the panel needs three different
|
||||||
|
things from it. `turn` is what a reader counts — the same `depth + 1` the
|
||||||
|
branch list shows. `depth` and `branch_id` are the coordinate itself.
|
||||||
|
`on_path` says whether the position lies on the story being read, which is
|
||||||
|
how the panel can tell a Save Point on this line from one naming a line the
|
||||||
|
story has left; restoring either works, but they are not the same offer.
|
||||||
|
|
||||||
|
`resolved` is false when the coordinate no longer names a live turn, which
|
||||||
|
an action deleted out of the middle of a story can do. Restore refuses such
|
||||||
|
a Save Point rather than moving the head somewhere approximate, so the list
|
||||||
|
says so before the button is pressed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
adventure_id: int
|
||||||
|
name: str
|
||||||
|
note: str = ""
|
||||||
|
branch_id: int
|
||||||
|
depth: int
|
||||||
|
turn: int = 0
|
||||||
|
on_path: bool = True
|
||||||
|
resolved: bool = True
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class CheckpointCreate(BaseModel):
|
||||||
|
"""A Save Point at wherever the story is being read.
|
||||||
|
|
||||||
|
The position is not a field. A Save Point is made at the campaign's active
|
||||||
|
head, which the server already knows, and accepting a coordinate from the
|
||||||
|
client would be the second way to name a position — the thing this milestone
|
||||||
|
exists not to build.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: CheckpointName
|
||||||
|
note: Prose = ""
|
||||||
|
|
||||||
|
|
||||||
|
class CheckpointRename(BaseModel):
|
||||||
|
"""A new label, and nothing else.
|
||||||
|
|
||||||
|
There is deliberately no coordinate here. `STORY-BRANCH-SEMANTICS.md` §24
|
||||||
|
keeps a Save Point's meaning auditable by refusing to move one: rename it,
|
||||||
|
or delete it and make another where you are.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: CheckpointName | None = None
|
||||||
|
note: Prose | None = None
|
||||||
|
|
||||||
|
|
||||||
class ActionUpdate(BaseModel):
|
class ActionUpdate(BaseModel):
|
||||||
text: ActionText
|
text: ActionText
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""The storyteller, run as a real OS process for `test_process_restart.py`.
|
||||||
|
|
||||||
|
Not a test module, and named so pytest does not collect it: it is the program
|
||||||
|
the test starts, twice, against one database file.
|
||||||
|
|
||||||
|
The model is replaced with a deterministic fake before the app is imported, so
|
||||||
|
the process needs no Ollama, no network and no configuration. Everything else —
|
||||||
|
the engine, the migrations, the routers, the session lifecycle — is the real
|
||||||
|
application, which is the whole point of spawning a process at all.
|
||||||
|
|
||||||
|
python _restart_server.py <db_path> <port>
|
||||||
|
"""
|
||||||
|
import itertools
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
HERE = Path(__file__).resolve().parent
|
||||||
|
sys.path.insert(0, str(HERE.parent)) # backend/, so `app` imports
|
||||||
|
sys.path.insert(0, str(HERE)) # tests/, so `fakes` imports
|
||||||
|
|
||||||
|
db_path, port = sys.argv[1], int(sys.argv[2])
|
||||||
|
os.environ["AIDND_DB_PATH"] = db_path
|
||||||
|
# A developer's shell may point these at Postgres, and `app.database` prefers
|
||||||
|
# either over the SQLite path. The suite's conftest clears them for the same
|
||||||
|
# reason; a spawned process does not inherit that, so clear them here too.
|
||||||
|
os.environ.pop("AIDND_DATABASE_URL", None)
|
||||||
|
os.environ.pop("DATABASE_URL", None)
|
||||||
|
|
||||||
|
from fakes import GOLD_PER_TURN, gold_reply # noqa: E402
|
||||||
|
|
||||||
|
_turn = itertools.count(1)
|
||||||
|
|
||||||
|
|
||||||
|
class DeterministicProvider:
|
||||||
|
"""Banks one turn's worth of gold per reply, numbered so text is checkable.
|
||||||
|
|
||||||
|
The same instrumentation `test_head_cursor.py` and `test_save_points.py`
|
||||||
|
use, for the same reason: it makes "the state at this position" a number the
|
||||||
|
test can assert rather than a paragraph it has to interpret. M5 replaces the
|
||||||
|
machinery underneath; what this measures is where the story is being read.
|
||||||
|
"""
|
||||||
|
|
||||||
|
last_usage = None
|
||||||
|
|
||||||
|
def __init__(self, *a, **k):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def generate(self, parts, *, temperature, max_tokens):
|
||||||
|
yield ("text", gold_reply(f"Beat {next(_turn)}."))
|
||||||
|
|
||||||
|
|
||||||
|
from app.routers.adventures import turns # noqa: E402
|
||||||
|
|
||||||
|
turns.OpenAICompatibleProvider = DeterministicProvider
|
||||||
|
|
||||||
|
from app.main import app # noqa: E402
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
# Loopback only, as every supported start path does.
|
||||||
|
uvicorn.run(app, host="127.0.0.1", port=port, log_level="warning")
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
"""D11 and L03 across a genuine OS process boundary.
|
||||||
|
|
||||||
|
The rest of the suite runs the app in-process through `TestClient`, which is the
|
||||||
|
right tool for almost everything and the wrong one for exactly one claim:
|
||||||
|
*durability*. A Save Point that survived only because a Python object was still
|
||||||
|
alive would pass a same-process test and fail a user's restart. `TestClient`
|
||||||
|
cannot tell those apart, so the M4 review recorded the shipped D11/L03 tests as
|
||||||
|
weaker than the acceptance items they were named for (§R B-3).
|
||||||
|
|
||||||
|
This module closes that. It starts the real application as a **subprocess**,
|
||||||
|
plays a story over HTTP, kills the process, starts a **second** process against
|
||||||
|
the same database file, and only then asks whether the Save Point is still
|
||||||
|
there. Everything crossing the boundary crosses it as bytes on disk.
|
||||||
|
|
||||||
|
Deterministic and local: the spawned server replaces the model with a scripted
|
||||||
|
provider (`_restart_server.py`), so there is no Ollama, no network and no
|
||||||
|
sleep-and-hope — readiness is probed, not waited for.
|
||||||
|
|
||||||
|
python -m pytest tests/test_process_restart.py -v
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from fakes import GOLD_PER_TURN
|
||||||
|
|
||||||
|
HERE = Path(__file__).resolve().parent
|
||||||
|
SERVER = HERE / "_restart_server.py"
|
||||||
|
|
||||||
|
# How long a spawned server may take to answer before the test gives up. The
|
||||||
|
# process imports the app and runs migrations on a fresh file, which is well
|
||||||
|
# under a second on this project; the ceiling is for a loaded machine.
|
||||||
|
STARTUP_TIMEOUT = 60.0
|
||||||
|
|
||||||
|
|
||||||
|
def _free_port() -> int:
|
||||||
|
"""Returns a port nothing is listening on.
|
||||||
|
|
||||||
|
Bind, read, release. There is a race between releasing and the server
|
||||||
|
claiming it, which is why the caller probes for readiness rather than
|
||||||
|
assuming success — a lost race shows up as a startup timeout, not as a
|
||||||
|
silent pass.
|
||||||
|
"""
|
||||||
|
with socket.socket() as s:
|
||||||
|
s.bind(("127.0.0.1", 0))
|
||||||
|
return s.getsockname()[1]
|
||||||
|
|
||||||
|
|
||||||
|
class Server:
|
||||||
|
"""One storyteller process, and the HTTP calls the test makes against it."""
|
||||||
|
|
||||||
|
def __init__(self, db_path: str, port: int):
|
||||||
|
self.port = port
|
||||||
|
self.proc = subprocess.Popen(
|
||||||
|
[sys.executable, str(SERVER), db_path, str(port)],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
# Never inherit the parent's database redirection; the child is told
|
||||||
|
# which file to open on its command line.
|
||||||
|
env={**os.environ, "AIDND_DB_PATH": db_path},
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ lifecycle
|
||||||
|
|
||||||
|
def wait_until_ready(self) -> None:
|
||||||
|
deadline = time.monotonic() + STARTUP_TIMEOUT
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
if self.proc.poll() is not None:
|
||||||
|
raise AssertionError(
|
||||||
|
f"server exited early ({self.proc.returncode}):\n{self._output()}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
self.call("GET", "/settings")
|
||||||
|
return
|
||||||
|
except (urllib.error.URLError, ConnectionError, OSError):
|
||||||
|
time.sleep(0.05)
|
||||||
|
raise AssertionError(f"server never became ready:\n{self._output()}")
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""Ends the process, and does not return until it is actually gone."""
|
||||||
|
if self.proc.poll() is None:
|
||||||
|
self.proc.terminate()
|
||||||
|
try:
|
||||||
|
self.proc.wait(timeout=15)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
self.proc.kill()
|
||||||
|
self.proc.wait(timeout=15)
|
||||||
|
if self.proc.stdout is not None:
|
||||||
|
self.proc.stdout.close()
|
||||||
|
|
||||||
|
def _output(self) -> str:
|
||||||
|
if self.proc.stdout is None:
|
||||||
|
return "(no output captured)"
|
||||||
|
try:
|
||||||
|
return self.proc.stdout.read().decode(errors="replace")[-2000:]
|
||||||
|
except Exception:
|
||||||
|
return "(output unreadable)"
|
||||||
|
|
||||||
|
def is_listening(self) -> bool:
|
||||||
|
try:
|
||||||
|
self.call("GET", "/settings")
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- HTTP
|
||||||
|
|
||||||
|
def call(self, method: str, path: str, payload=None, expect: int | None = None):
|
||||||
|
data = json.dumps(payload).encode() if payload is not None else None
|
||||||
|
request = urllib.request.Request(
|
||||||
|
f"http://127.0.0.1:{self.port}/api{path}",
|
||||||
|
data=data,
|
||||||
|
method=method,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=60) as response:
|
||||||
|
body, status = response.read(), response.status
|
||||||
|
except urllib.error.HTTPError as exc: # a real answer, not a failure
|
||||||
|
body, status = exc.read(), exc.code
|
||||||
|
if expect is not None and status != expect:
|
||||||
|
raise AssertionError(f"{method} {path} -> {status}: {body[:400]!r}")
|
||||||
|
return json.loads(body) if body and status != 204 else None
|
||||||
|
|
||||||
|
def play(self, adventure_id: int, text: str) -> None:
|
||||||
|
"""Plays one turn through the streaming endpoint, to completion."""
|
||||||
|
request = urllib.request.Request(
|
||||||
|
f"http://127.0.0.1:{self.port}/api/adventures/{adventure_id}/actions",
|
||||||
|
data=json.dumps({"type": "do", "text": text}).encode(),
|
||||||
|
method="POST",
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(request, timeout=120) as response:
|
||||||
|
response.read()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- reading
|
||||||
|
|
||||||
|
def transcript(self, adventure_id: int) -> list[str]:
|
||||||
|
page = self.call("GET", f"/adventures/{adventure_id}", expect=200)
|
||||||
|
return [a["text"] for a in page["actions"]]
|
||||||
|
|
||||||
|
def gold(self, adventure_id: int) -> int:
|
||||||
|
state = self.call("GET", f"/adventures/{adventure_id}/world-state", expect=200)
|
||||||
|
return state["state"]["player"]["gold"]
|
||||||
|
|
||||||
|
def total_rows(self, adventure_id: int) -> int:
|
||||||
|
"""Every row of the whole tree, head or no head.
|
||||||
|
|
||||||
|
`action_count` is scoped to the path being read, so it falls when the
|
||||||
|
head moves back — which is the feature, not a deletion. The export
|
||||||
|
carries the entire tree whatever the head is doing, so it is what
|
||||||
|
"nothing was deleted" has to be measured against.
|
||||||
|
"""
|
||||||
|
bundle = self.call("GET", f"/adventures/{adventure_id}/export", expect=200)
|
||||||
|
return len(bundle["actions"])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def workspace():
|
||||||
|
"""A database file, and whichever servers a test starts against it."""
|
||||||
|
directory = tempfile.mkdtemp(prefix="m4-restart-")
|
||||||
|
db_path = os.path.join(directory, "campaign.db")
|
||||||
|
started: list[Server] = []
|
||||||
|
|
||||||
|
def start() -> Server:
|
||||||
|
server = Server(db_path, _free_port())
|
||||||
|
started.append(server)
|
||||||
|
server.wait_until_ready()
|
||||||
|
return server
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield start
|
||||||
|
finally:
|
||||||
|
# Every child dies even if the test failed part way through, and each
|
||||||
|
# stop() waits, so a later test cannot inherit a live listener.
|
||||||
|
for server in started:
|
||||||
|
server.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def _campaign_with_a_save_point(server: Server):
|
||||||
|
"""Three turns, a Save Point on the third, then four more turns.
|
||||||
|
|
||||||
|
Returns everything the second process has to be able to reproduce.
|
||||||
|
"""
|
||||||
|
scenario = server.call("POST", "/scenarios", {
|
||||||
|
"title": "Abbey",
|
||||||
|
"stat_schema": {"player": {"gold": {"initial": 0, "min": 0, "max": 9999}}},
|
||||||
|
}, expect=201)
|
||||||
|
adventure = server.call("POST", "/adventures", {
|
||||||
|
"title": "The abbey", "scenario_id": scenario["id"],
|
||||||
|
}, expect=201)
|
||||||
|
adventure_id = adventure["id"]
|
||||||
|
|
||||||
|
for n in range(3):
|
||||||
|
server.play(adventure_id, f"turn {n}")
|
||||||
|
at_save = {
|
||||||
|
"transcript": server.transcript(adventure_id),
|
||||||
|
"gold": server.gold(adventure_id),
|
||||||
|
}
|
||||||
|
save_point = server.call("POST", f"/adventures/{adventure_id}/checkpoints",
|
||||||
|
{"name": "Before entering the abbey"}, expect=201)
|
||||||
|
|
||||||
|
for n in range(4):
|
||||||
|
server.play(adventure_id, f"later {n}")
|
||||||
|
at_tip = {
|
||||||
|
"transcript": server.transcript(adventure_id),
|
||||||
|
"gold": server.gold(adventure_id),
|
||||||
|
"rows": server.total_rows(adventure_id),
|
||||||
|
}
|
||||||
|
return adventure_id, save_point, at_save, at_tip
|
||||||
|
|
||||||
|
|
||||||
|
def test_d11_l03_a_save_point_survives_a_real_process_restart(workspace):
|
||||||
|
"""D11 and L03 together, across a boundary a same-process test cannot cross.
|
||||||
|
|
||||||
|
The first process writes the campaign and exits. The second process is a
|
||||||
|
different interpreter with an empty session, an empty identity map and no
|
||||||
|
memory of anything — everything it knows, it reads off the disk.
|
||||||
|
"""
|
||||||
|
first = workspace()
|
||||||
|
adventure_id, save_point, at_save, at_tip = _campaign_with_a_save_point(first)
|
||||||
|
|
||||||
|
assert at_tip["gold"] == at_save["gold"] + 4 * GOLD_PER_TURN
|
||||||
|
assert len(at_tip["transcript"]) == len(at_save["transcript"]) + 8
|
||||||
|
|
||||||
|
# --- the boundary -----------------------------------------------------
|
||||||
|
first.stop()
|
||||||
|
assert first.proc.poll() is not None, "the first server did not actually exit"
|
||||||
|
assert not first.is_listening(), "the first server is still answering"
|
||||||
|
|
||||||
|
second = workspace()
|
||||||
|
assert second.proc.pid != first.proc.pid
|
||||||
|
|
||||||
|
# --- D11: the Save Point is still there -------------------------------
|
||||||
|
listed = second.call("GET", f"/adventures/{adventure_id}/checkpoints", expect=200)
|
||||||
|
assert [c["id"] for c in listed] == [save_point["id"]]
|
||||||
|
assert listed[0]["name"] == "Before entering the abbey"
|
||||||
|
assert (listed[0]["branch_id"], listed[0]["depth"]) == (
|
||||||
|
save_point["branch_id"], save_point["depth"]
|
||||||
|
)
|
||||||
|
assert listed[0]["resolved"] is True
|
||||||
|
# The story came back too, at the position the first process left it.
|
||||||
|
assert second.transcript(adventure_id) == at_tip["transcript"]
|
||||||
|
|
||||||
|
rows_before_restore = second.total_rows(adventure_id)
|
||||||
|
assert rows_before_restore == at_tip["rows"]
|
||||||
|
|
||||||
|
# --- L03: restoring reconstructs the historical position --------------
|
||||||
|
page = second.call(
|
||||||
|
"POST", f"/adventures/{adventure_id}/checkpoints/{save_point['id']}/restore",
|
||||||
|
expect=200,
|
||||||
|
)
|
||||||
|
assert [a["text"] for a in page["actions"]] == at_save["transcript"]
|
||||||
|
assert second.transcript(adventure_id) == at_save["transcript"]
|
||||||
|
assert second.gold(adventure_id) == at_save["gold"]
|
||||||
|
|
||||||
|
# --- restore deleted nothing, and Redo is still available -------------
|
||||||
|
assert second.total_rows(adventure_id) == rows_before_restore
|
||||||
|
assert page["can_redo"] is True
|
||||||
|
assert second.call("GET", f"/adventures/{adventure_id}", expect=200)["can_redo"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_retained_continuation_is_reachable_after_a_restart(workspace):
|
||||||
|
"""The later history is not merely present in the database after a restart —
|
||||||
|
it is still the continuation the story tells, walkable by ordinary Redo."""
|
||||||
|
first = workspace()
|
||||||
|
adventure_id, save_point, at_save, at_tip = _campaign_with_a_save_point(first)
|
||||||
|
first.stop()
|
||||||
|
|
||||||
|
second = workspace()
|
||||||
|
second.call("POST",
|
||||||
|
f"/adventures/{adventure_id}/checkpoints/{save_point['id']}/restore",
|
||||||
|
expect=200)
|
||||||
|
assert second.transcript(adventure_id) == at_save["transcript"]
|
||||||
|
|
||||||
|
steps = 0
|
||||||
|
while second.call("GET", f"/adventures/{adventure_id}", expect=200)["can_redo"]:
|
||||||
|
second.call("POST", f"/adventures/{adventure_id}/redo", expect=200)
|
||||||
|
steps += 1
|
||||||
|
assert steps <= 10, "Redo never stopped"
|
||||||
|
|
||||||
|
assert steps == 4
|
||||||
|
assert second.transcript(adventure_id) == at_tip["transcript"]
|
||||||
|
assert second.gold(adventure_id) == at_tip["gold"]
|
||||||
|
assert second.total_rows(adventure_id) == at_tip["rows"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_divergent_write_after_a_restart_keeps_the_displaced_future(workspace):
|
||||||
|
"""D13's second half, across the boundary: the fork still happens on the
|
||||||
|
write rather than on the restore, and the displaced turns keep their rows."""
|
||||||
|
first = workspace()
|
||||||
|
adventure_id, save_point, at_save, at_tip = _campaign_with_a_save_point(first)
|
||||||
|
first.stop()
|
||||||
|
|
||||||
|
second = workspace()
|
||||||
|
second.call("POST",
|
||||||
|
f"/adventures/{adventure_id}/checkpoints/{save_point['id']}/restore",
|
||||||
|
expect=200)
|
||||||
|
branches_before = second.call("GET", f"/adventures/{adventure_id}/branches",
|
||||||
|
expect=200)
|
||||||
|
assert len(branches_before) == 1, "restore must not fork"
|
||||||
|
|
||||||
|
second.play(adventure_id, "go around the back")
|
||||||
|
|
||||||
|
branches_after = second.call("GET", f"/adventures/{adventure_id}/branches",
|
||||||
|
expect=200)
|
||||||
|
assert len(branches_after) == 2, "the write should have forked"
|
||||||
|
# Nothing was deleted to achieve it: the tree grew by the new turn only.
|
||||||
|
assert second.total_rows(adventure_id) == at_tip["rows"] + 2
|
||||||
|
# Ordinary Redo no longer offers the displaced future.
|
||||||
|
assert second.call("GET", f"/adventures/{adventure_id}", expect=200)["can_redo"] is False
|
||||||
|
# And the Save Point still names the position it always did.
|
||||||
|
listed = second.call("GET", f"/adventures/{adventure_id}/checkpoints", expect=200)
|
||||||
|
assert (listed[0]["branch_id"], listed[0]["depth"]) == (
|
||||||
|
save_point["branch_id"], save_point["depth"]
|
||||||
|
)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -119,7 +119,12 @@ def pre_tree():
|
|||||||
# Dropping the tables is the only way to remove the columns.
|
# Dropping the tables is the only way to remove the columns.
|
||||||
# SQLite refuses to drop a column that a foreign key references,
|
# SQLite refuses to drop a column that a foreign key references,
|
||||||
# and that is exactly the case for `branch_id`.
|
# and that is exactly the case for `branch_id`.
|
||||||
for table in ("actions", "memories", "branches", "adventures"):
|
#
|
||||||
|
# `checkpoints` (M4) is dropped first and for a different reason: it
|
||||||
|
# references both `branches` and `adventures`, and SQLite refuses to
|
||||||
|
# drop a table another table still points at. Any future table that
|
||||||
|
# references these four has to be added to the front of this list.
|
||||||
|
for table in ("checkpoints", "actions", "memories", "branches", "adventures"):
|
||||||
conn.execute(text(f"DROP TABLE IF EXISTS {table}"))
|
conn.execute(text(f"DROP TABLE IF EXISTS {table}"))
|
||||||
for ddl in PRE_TREE_DDL:
|
for ddl in PRE_TREE_DDL:
|
||||||
conn.execute(text(ddl))
|
conn.execute(text(ddl))
|
||||||
@@ -592,7 +597,7 @@ def pre_split():
|
|||||||
Base.metadata.drop_all(bind=engine)
|
Base.metadata.drop_all(bind=engine)
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
with engine.begin() as conn:
|
with engine.begin() as conn:
|
||||||
for table in ("actions", "memories", "branches", "adventures"):
|
for table in ("checkpoints", "actions", "memories", "branches", "adventures"):
|
||||||
conn.execute(text(f"DROP TABLE IF EXISTS {table}"))
|
conn.execute(text(f"DROP TABLE IF EXISTS {table}"))
|
||||||
for ddl in PRE_TREE_DDL:
|
for ddl in PRE_TREE_DDL:
|
||||||
conn.execute(text(ddl))
|
conn.execute(text(ddl))
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { CORNER, PAD, ROW_H, branchLabel, headLineage, layoutTree, momentTicks } from './branches'
|
import { CORNER, PAD, ROW_H, branchLabel, headLineage, layoutTree, momentTicks, savePointsUnder } from './branches'
|
||||||
|
|
||||||
// The story tree, drawn.
|
// The story tree, drawn.
|
||||||
//
|
//
|
||||||
@@ -108,6 +108,9 @@ export function BranchMap({ branches, busyId, onSwitch, onRename, onDelete, onCl
|
|||||||
// would throw away the only copy of it.
|
// would throw away the only copy of it.
|
||||||
const save = async () => { if (await onRename(selected, renameText)) setRenameText(null) }
|
const save = async () => { if (await onRename(selected, renameText)) setRenameText(null) }
|
||||||
const drop = async () => { if (await onDelete(selected)) setConfirming(false) }
|
const drop = async () => { if (await onDelete(selected)) setConfirming(false) }
|
||||||
|
// Save Points naming a moment on the selected branch, or on anything forked
|
||||||
|
// from it, keep it alive — the server refuses to delete them along with it.
|
||||||
|
const protecting = selected ? savePointsUnder(branches, selected.id) : 0
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="modal-overlay branch-map-overlay" onClick={onClose}>
|
<div className="modal-overlay branch-map-overlay" onClick={onClose}>
|
||||||
@@ -239,7 +242,13 @@ export function BranchMap({ branches, busyId, onSwitch, onRename, onDelete, onCl
|
|||||||
|
|
||||||
{confirming ? (
|
{confirming ? (
|
||||||
<div className="branch-confirm">
|
<div className="branch-confirm">
|
||||||
<span>Delete this branch and everything forked from it?</span>
|
{/* The same wording the list gives, because the same deletion
|
||||||
|
is reachable from both views and a rule that held in one of
|
||||||
|
them would not be a rule. */}
|
||||||
|
<span>
|
||||||
|
Delete this branch and everything forked from it? The story on
|
||||||
|
other paths, and every Save Point, is unaffected.
|
||||||
|
</span>
|
||||||
<button type="button" className="danger" disabled={busy} onClick={drop}>
|
<button type="button" className="danger" disabled={busy} onClick={drop}>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
@@ -265,10 +274,14 @@ export function BranchMap({ branches, busyId, onSwitch, onRename, onDelete, onCl
|
|||||||
the head stands on is offered, and says why it cannot go. */}
|
the head stands on is offered, and says why it cannot go. */}
|
||||||
{!isRoot && (
|
{!isRoot && (
|
||||||
<button type="button" className="danger"
|
<button type="button" className="danger"
|
||||||
disabled={busy || isLoadBearing}
|
disabled={busy || isLoadBearing || protecting > 0}
|
||||||
title={isLoadBearing
|
title={
|
||||||
|
isLoadBearing
|
||||||
? 'The line you are reading is built on this one. Switch away first.'
|
? 'The line you are reading is built on this one. Switch away first.'
|
||||||
: undefined}
|
: protecting > 0
|
||||||
|
? `${protecting === 1 ? 'A Save Point is' : `${protecting} Save Points are`} saved on this line or one forked from it. Delete ${protecting === 1 ? 'it' : 'them'} first — deleting a Save Point deletes no story.`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onClick={() => setConfirming(true)}>Delete</button>
|
onClick={() => setConfirming(true)}>Delete</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -106,6 +106,25 @@ export const api = {
|
|||||||
}),
|
}),
|
||||||
deleteBranch: (advId, branchId) =>
|
deleteBranch: (advId, branchId) =>
|
||||||
request(`/adventures/${advId}/branches/${branchId}`, { method: 'DELETE' }),
|
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
|
// 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.
|
// own takes the text given. Streams, because it is a turn like any other.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -61,6 +61,41 @@ export function headLineage(branches) {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// How many Save Points would go if this branch were deleted.
|
||||||
|
//
|
||||||
|
// Deleting a branch deletes everything forked from it, and a Save Point names a
|
||||||
|
// position on a line, so the Save Points on the whole doomed subtree go too.
|
||||||
|
// The count is summed over that subtree rather than over the one branch — a
|
||||||
|
// warning that said "1 Save Point" while three disappeared would be worse than
|
||||||
|
// no warning at all.
|
||||||
|
//
|
||||||
|
// Computed here because the panel already holds every branch and its parent,
|
||||||
|
// so the answer costs a walk rather than an endpoint. The server remains the
|
||||||
|
// authority on what is actually deleted; this only lets the button say so
|
||||||
|
// before it is pressed, the same division `headLineage` already uses.
|
||||||
|
export function savePointsUnder(branches, rootId) {
|
||||||
|
const children = new Map()
|
||||||
|
for (const b of branches) {
|
||||||
|
const key = b.parent_branch_id
|
||||||
|
if (!children.has(key)) children.set(key, [])
|
||||||
|
children.get(key).push(b)
|
||||||
|
}
|
||||||
|
const byId = new Map(branches.map((b) => [b.id, b]))
|
||||||
|
const seen = new Set()
|
||||||
|
const stack = [rootId]
|
||||||
|
let total = 0
|
||||||
|
while (stack.length) {
|
||||||
|
const id = stack.pop()
|
||||||
|
// The guard is against a cycle, which the schema forbids and a walk should
|
||||||
|
// still never hang on.
|
||||||
|
if (seen.has(id)) continue
|
||||||
|
seen.add(id)
|
||||||
|
total += byId.get(id)?.save_points ?? 0
|
||||||
|
for (const child of children.get(id) ?? []) stack.push(child.id)
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Map geometry ----------
|
// ---------- Map geometry ----------
|
||||||
|
|
||||||
export const ROW_H = 64 // one branch, name above the lane and meta below
|
export const ROW_H = 64 // one branch, name above the lane and meta below
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { BranchPanel } from './panels/BranchPanel'
|
|||||||
import { InsightsPanel } from './panels/InsightsPanel'
|
import { InsightsPanel } from './panels/InsightsPanel'
|
||||||
import { MemoryPanel } from './panels/MemoryPanel'
|
import { MemoryPanel } from './panels/MemoryPanel'
|
||||||
import { PlotPanel } from './panels/PlotPanel'
|
import { PlotPanel } from './panels/PlotPanel'
|
||||||
|
import { SavePointPanel } from './panels/SavePointPanel'
|
||||||
|
|
||||||
const MODES = ['do', 'say', 'story']
|
const MODES = ['do', 'say', 'story']
|
||||||
const PLAYER_TYPES = ['do', 'say', 'story']
|
const PLAYER_TYPES = ['do', 'say', 'story']
|
||||||
@@ -68,7 +69,7 @@ export default function Play() {
|
|||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [toast, setToast] = useState(null)
|
const [toast, setToast] = useState(null)
|
||||||
const [editing, setEditing] = 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
|
// Bumped when something outside the turn loop changes the drawers' state
|
||||||
// (currently "Update from scenario"), which no action count would reflect.
|
// (currently "Update from scenario"), which no action count would reflect.
|
||||||
const [stateKey, setStateKey] = useState(0)
|
const [stateKey, setStateKey] = useState(0)
|
||||||
@@ -545,6 +546,8 @@ export default function Play() {
|
|||||||
onClick={() => setPanel(panel === 'memory' ? null : 'memory')}>Memory</button>
|
onClick={() => setPanel(panel === 'memory' ? null : 'memory')}>Memory</button>
|
||||||
<button className={panel === 'branches' ? 'active' : ''}
|
<button className={panel === 'branches' ? 'active' : ''}
|
||||||
onClick={() => setPanel(panel === 'branches' ? null : 'branches')}>Branches</button>
|
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' : ''}
|
<button className={panel === 'insights' ? 'active' : ''}
|
||||||
onClick={() => { setInspectActionId(null); setPanel(panel === 'insights' ? null : 'insights') }}>
|
onClick={() => { setInspectActionId(null); setPanel(panel === 'insights' ? null : 'insights') }}>
|
||||||
Insights
|
Insights
|
||||||
@@ -714,6 +717,13 @@ export default function Play() {
|
|||||||
from here: that future is retained, but it is no longer the
|
from here: that future is retained, but it is no longer the
|
||||||
continuation this story tells. */}
|
continuation this story tells. */}
|
||||||
<button onClick={redo} disabled={busy || !history.redo} title="Ctrl+Shift+Z">↷ Redo</button>
|
<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>
|
||||||
<div className="input-bar">
|
<div className="input-bar">
|
||||||
<div className="mode-select">
|
<div className="mode-select">
|
||||||
@@ -752,7 +762,10 @@ export default function Play() {
|
|||||||
{panel && (
|
{panel && (
|
||||||
<div className="side-panel">
|
<div className="side-panel">
|
||||||
<div className="side-panel-header">
|
<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>
|
<button onClick={() => setPanel(null)}>✕</button>
|
||||||
</div>
|
</div>
|
||||||
{panel === 'plot' ? (
|
{panel === 'plot' ? (
|
||||||
@@ -764,6 +777,17 @@ export default function Play() {
|
|||||||
// but deleting a branch deletes the memories that hung off it,
|
// but deleting a branch deletes the memories that hung off it,
|
||||||
// and that happens without a turn being played.
|
// and that happens without a turn being played.
|
||||||
refreshKey={`${actions.length}:${stateKey}`} />
|
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' ? (
|
) : panel === 'branches' ? (
|
||||||
<BranchPanel
|
<BranchPanel
|
||||||
advId={id}
|
advId={id}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { api } from '../../../api'
|
import { api } from '../../../api'
|
||||||
import { BranchMap } from '../../../BranchMap'
|
import { BranchMap } from '../../../BranchMap'
|
||||||
import { branchLabel, headLineage, orderBranches } from '../../../branches'
|
import { branchLabel, headLineage, orderBranches, savePointsUnder } from '../../../branches'
|
||||||
|
|
||||||
function BranchPanel({ advId, refreshKey, onSwitched, onTreeChanged, onError }) {
|
function BranchPanel({ advId, refreshKey, onSwitched, onTreeChanged, onError }) {
|
||||||
const [branches, setBranches] = useState(null)
|
const [branches, setBranches] = useState(null)
|
||||||
@@ -88,6 +88,12 @@ function BranchPanel({ advId, refreshKey, onSwitched, onTreeChanged, onError })
|
|||||||
// was forked from. The button said nothing about that and answered
|
// was forked from. The button said nothing about that and answered
|
||||||
// with a toast; it now says so before it is pressed.
|
// with a toast; it now says so before it is pressed.
|
||||||
const loadBearing = lineage.has(branch.id)
|
const loadBearing = lineage.has(branch.id)
|
||||||
|
// A Save Point naming a moment on this branch, or on anything forked
|
||||||
|
// from it, keeps the branch alive: named moments last until the user
|
||||||
|
// deletes them, so the server refuses the deletion rather than taking
|
||||||
|
// them with it. Counted over the subtree, because deleting a branch
|
||||||
|
// takes its descendants.
|
||||||
|
const protecting = savePointsUnder(branches, branch.id)
|
||||||
return (
|
return (
|
||||||
<div key={branch.id} className={`branch-row ${branch.is_head ? 'here' : ''}`}
|
<div key={branch.id} className={`branch-row ${branch.is_head ? 'here' : ''}`}
|
||||||
style={{ marginLeft: indent * 12 }}>
|
style={{ marginLeft: indent * 12 }}>
|
||||||
@@ -117,11 +123,20 @@ function BranchPanel({ advId, refreshKey, onSwitched, onTreeChanged, onError })
|
|||||||
{branch.own_actions} of its own
|
{branch.own_actions} of its own
|
||||||
{branch.parent_branch_id !== null && ` · forked at moment ${branch.fork_depth + 1}`}
|
{branch.parent_branch_id !== null && ` · forked at moment ${branch.fork_depth + 1}`}
|
||||||
{` · ends at ${branch.depth + 1}`}
|
{` · ends at ${branch.depth + 1}`}
|
||||||
|
{protecting > 0 &&
|
||||||
|
` · ${protecting === 1 ? '1 Save Point' : `${protecting} Save Points`} kept here`}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isConfirming ? (
|
{isConfirming ? (
|
||||||
<div className="branch-confirm">
|
<div className="branch-confirm">
|
||||||
<span>Delete this branch and everything forked from it?</span>
|
{/* Only reachable when no Save Point points into this
|
||||||
|
subtree — the button is disabled otherwise, and the server
|
||||||
|
refuses regardless. So this says what the deletion costs
|
||||||
|
without hedging about Save Points that cannot be at risk. */}
|
||||||
|
<span>
|
||||||
|
Delete this branch and everything forked from it? The story
|
||||||
|
on other paths, and every Save Point, is unaffected.
|
||||||
|
</span>
|
||||||
<button type="button" className="danger" disabled={busy}
|
<button type="button" className="danger" disabled={busy}
|
||||||
onClick={() => remove(branch)}>Delete</button>
|
onClick={() => remove(branch)}>Delete</button>
|
||||||
<button type="button" onClick={() => setConfirming(null)}>Keep</button>
|
<button type="button" onClick={() => setConfirming(null)}>Keep</button>
|
||||||
@@ -145,10 +160,15 @@ function BranchPanel({ advId, refreshKey, onSwitched, onTreeChanged, onError })
|
|||||||
{/* The root holds the turns every other branch borrows, and
|
{/* The root holds the turns every other branch borrows, and
|
||||||
the server refuses it — so it is not offered. */}
|
the server refuses it — so it is not offered. */}
|
||||||
{branch.parent_branch_id !== null && (
|
{branch.parent_branch_id !== null && (
|
||||||
<button type="button" className="danger" disabled={busy || loadBearing}
|
<button type="button" className="danger"
|
||||||
title={loadBearing
|
disabled={busy || loadBearing || protecting > 0}
|
||||||
|
title={
|
||||||
|
loadBearing
|
||||||
? 'The line you are reading is built on this one. Switch away first.'
|
? 'The line you are reading is built on this one. Switch away first.'
|
||||||
: undefined}
|
: protecting > 0
|
||||||
|
? `${protecting === 1 ? 'A Save Point is' : `${protecting} Save Points are`} saved on this line or one forked from it. Delete ${protecting === 1 ? 'it' : 'them'} first — deleting a Save Point deletes no story.`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onClick={() => setConfirming(branch.id)}>Delete</button>
|
onClick={() => setConfirming(branch.id)}>Delete</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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%;
|
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; }
|
.action-edit { margin-bottom: 14px; }
|
||||||
/* Sized by AutoTextarea to fit the text being edited — an AI beat is usually
|
/* 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.
|
several paragraphs, and the old fixed 110px turned that into a keyhole.
|
||||||
|
|||||||
@@ -358,10 +358,16 @@ Each entry:
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
Before entering the abbey
|
Before entering the abbey
|
||||||
Turn 42
|
Moment 42
|
||||||
[Restore] [Rename] [Delete]
|
[Restore] [Rename] [Delete]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Moment, not Turn.** M4 closeout ruled in favour of the implemented product:
|
||||||
|
the branch panel and the tree overlay already count in moments (`forked at
|
||||||
|
moment 9`, `ends at moment 40`), so a Save Point list saying "Turn 42" would
|
||||||
|
make one screen use two words for one thing. This is a vocabulary alignment and
|
||||||
|
changes no behaviour; the number is unchanged.
|
||||||
|
|
||||||
## 26. Restore Confirmation
|
## 26. Restore Confirmation
|
||||||
|
|
||||||
Restoring is non-destructive.
|
Restoring is non-destructive.
|
||||||
|
|||||||
+129
-10
@@ -1,6 +1,6 @@
|
|||||||
# Adventure Storyteller — Production Build Milestones
|
# Adventure Storyteller — Production Build Milestones
|
||||||
|
|
||||||
**Status:** In implementation. M1 and M2 complete (2026-09-02); M3 next
|
**Status:** In implementation. M1-M4 complete and accepted (M1 and M2: 2026-09-02; M3 and M4: 2026-09-03); M5 — Genre-Neutral Authoritative Narrative State — next to brief
|
||||||
**Base:** AI-DnD `d72f7c1bda0f34fccd84afb7a25c34eb01c901de`
|
**Base:** AI-DnD `d72f7c1bda0f34fccd84afb7a25c34eb01c901de`
|
||||||
|
|
||||||
## 1. Purpose
|
## 1. Purpose
|
||||||
@@ -250,7 +250,7 @@ History operations are non-destructive, Redo works, divergence preserves old fut
|
|||||||
|
|
||||||
## Status: COMPLETE
|
## Status: COMPLETE
|
||||||
|
|
||||||
Accepted 2026-09-03. Evidence: `planning/reports/M3-IMPLEMENTATION-REPORT.md`,
|
Accepted 2026-09-03. Evidence: `planning/archive/milestone-reports/M3-IMPLEMENTATION-REPORT.md`,
|
||||||
which is M3's primary evidence record — no separate baseline report was produced,
|
which is M3's primary evidence record — no separate baseline report was produced,
|
||||||
so that document carries the raw counts and runtime observations as well as the
|
so that document carries the raw counts and runtime observations as well as the
|
||||||
review. The architecture is recorded in **ADR 012**.
|
review. The architecture is recorded in **ADR 012**.
|
||||||
@@ -274,14 +274,18 @@ review. The architecture is recorded in **ADR 012**.
|
|||||||
a turn says while story descends from it off screen — both ratified in
|
a turn says while story descends from it off screen — both ratified in
|
||||||
`STORY-BRANCH-SEMANTICS.md` (§5, §10, §14A).
|
`STORY-BRANCH-SEMANTICS.md` (§5, §10, §14A).
|
||||||
|
|
||||||
**Outstanding closeout condition:** the required **browser smoke test has not
|
**Closeout condition — CLOSED at M4 closeout (2026-09-03).** M3 was accepted with
|
||||||
been performed** — no session in which M3 was implemented or reviewed had a
|
one condition outstanding: the required **browser smoke test had not been
|
||||||
browser available. The equivalent sequence was driven end-to-end against the
|
performed**, because no session in which M3 was implemented or reviewed had a
|
||||||
running application with real inference and a process restart, and every
|
browser available, leaving the DOM-level behaviour of the Redo button and its
|
||||||
server-side behaviour it covers passes; the DOM-level behaviour of the Redo
|
disabled states unverified by observation.
|
||||||
button, its disabled states and its keyboard shortcut remain unverified by
|
|
||||||
observation. This does not block M4, which touches none of that wiring, but it
|
That condition is now discharged. A real Firefox 154.0.1, driven through
|
||||||
remains an open M3 item until a human runs it.
|
geckodriver, exercised M3's controls in the rendered application: Undo enabled
|
||||||
|
and Redo disabled at the tip, two Undos moving the transcript back, Redo becoming
|
||||||
|
enabled and returning the original tip exactly, Retry and the take pager, and a
|
||||||
|
divergent write retiring Redo with no stale old-future text on screen. It passed.
|
||||||
|
Evidence: `planning/reports/M4-IMPLEMENTATION-REPORT.md` §W.7.
|
||||||
|
|
||||||
**Debt carried forward, none of it blocking M4:** full narrator-edit state
|
**Debt carried forward, none of it blocking M4:** full narrator-edit state
|
||||||
re-evaluation is deferred to M5 (`STORY-BRANCH-SEMANTICS.md` §14A records the
|
re-evaluation is deferred to M5 (`STORY-BRANCH-SEMANTICS.md` §14A records the
|
||||||
@@ -351,6 +355,121 @@ M3's cost was reconciling them; a parallel checkpoint mover would recreate that
|
|||||||
divergence in a place where the two paths would silently disagree about what
|
divergence in a place where the two paths would silently disagree about what
|
||||||
"restore" means. See ADR 012.
|
"restore" means. See ADR 012.
|
||||||
|
|
||||||
|
## Status: COMPLETE
|
||||||
|
|
||||||
|
Accepted 2026-09-03. Evidence: `planning/reports/M4-IMPLEMENTATION-REPORT.md`,
|
||||||
|
including its §W closeout addendum. The Definition of Done is met, and — for the
|
||||||
|
first time in this project — **verified in a real browser**.
|
||||||
|
|
||||||
|
**What M4 delivered:**
|
||||||
|
|
||||||
|
- **`checkpoints`**, a table holding a name, an optional note, and a
|
||||||
|
`(branch, depth)` coordinate — and no copy of any story. `create_all` builds
|
||||||
|
it, as it did `memories` and `branches`; migration 80 adds the index. No
|
||||||
|
backfill: nobody had named a position before M4, and inventing one would be
|
||||||
|
inventing the decision.
|
||||||
|
- **Create / list / rename / delete / restore** under
|
||||||
|
`/api/adventures/{id}/checkpoints`, campaign-scoped, with a Save Point from
|
||||||
|
another campaign a 404 rather than a restore of the wrong story.
|
||||||
|
- **Create at the active head, not the retained tip**, so a Save Point made after
|
||||||
|
two Undos names the undone position.
|
||||||
|
- **Restore that delegates**, and is the whole of the milestone's architecture:
|
||||||
|
resolve the coordinate, refuse it if it names no live turn, then
|
||||||
|
`head.move_to_node` — one function whose depth half is M3's `head.move_to`
|
||||||
|
unchanged, and whose branch half is the single assignment `switch_branch`
|
||||||
|
makes. Restore forks nothing.
|
||||||
|
- **A browser Save Point panel** — create form, list, Restore, Rename, Delete,
|
||||||
|
with both confirmations saying what is *not* destroyed — plus a Save Point
|
||||||
|
button beside Undo and Redo, where it belongs. No branch explorer, no
|
||||||
|
discarded-history browser, no merge UI.
|
||||||
|
- **Export/import of Save Points** with no format version bump, and a pre-M4
|
||||||
|
bundle importing with none.
|
||||||
|
|
||||||
|
**The one architectural decision M4 had to make**, which ADR 012 does not settle:
|
||||||
|
a Save Point can name a position on a line the story has since left, so restore
|
||||||
|
moves the branch half of the head as well — but *only* when the coordinate is not
|
||||||
|
on the path being read. Doing it unconditionally would quietly hand back an
|
||||||
|
abandoned continuation whenever a Save Point in a shared prefix was restored. No
|
||||||
|
new ADR: this is ADR 012's mechanism applied to both halves of a coordinate ADR
|
||||||
|
012 already defines, not a new architecture. `TECHNICAL-DESIGN.md` §8.8 records
|
||||||
|
it.
|
||||||
|
|
||||||
|
**Tests:** 42 in `backend/tests/test_save_points.py`, covering D11-D14, I04, L03,
|
||||||
|
E-series lineage and memory isolation after restore and divergence, the edge
|
||||||
|
cases in the brief, and the M3-database migration.
|
||||||
|
|
||||||
|
**Facts M5 inherits, and must not redesign:**
|
||||||
|
|
||||||
|
- a Save Point is a **durable story coordinate** — `(branch, depth)` — carrying
|
||||||
|
no copy of transcript, state, prompt, memory or summary;
|
||||||
|
- **restore is M3 head movement**, through the same `head.move_to` Undo and Redo
|
||||||
|
use; there is no second restore path and M5 must not add one;
|
||||||
|
- **state recovery stays snapshot/cached-position based**, never a replay of the
|
||||||
|
campaign (`TECHNICAL-DESIGN.md` §10.4). This is now load-bearing for Save
|
||||||
|
Points as well as Undo/Redo;
|
||||||
|
- **the first divergent write** after a restore creates the continuation;
|
||||||
|
restore itself never forks;
|
||||||
|
- **history a Save Point names cannot disappear** through an unrelated deletion
|
||||||
|
(§19.1);
|
||||||
|
- **M3/M4 history and Save Point semantics are infrastructure now.** M5 replaces
|
||||||
|
the state *model*; it does not revisit how the story is positioned.
|
||||||
|
|
||||||
|
**Acceptance evidence:** D11-D14, I04 and L03 all pass. D11 and L03 are
|
||||||
|
discharged by automation that crosses a **genuine OS process boundary** — one
|
||||||
|
server process writes the campaign, is killed, and a second process reads it back
|
||||||
|
— rather than by recreating a client in one process.
|
||||||
|
|
||||||
|
**The browser condition is closed, for M4 and retrospectively for M3.** A real
|
||||||
|
Firefox 154.0.1, driven through geckodriver over the W3C WebDriver protocol,
|
||||||
|
exercised the rendered DOM end to end: **44/44 checks passed**, covering M3's
|
||||||
|
Undo/Redo enable states and transcript movement, Retry and the take pager, M3
|
||||||
|
divergence and the disappearance of Redo, and every M4 Save Point operation
|
||||||
|
including both confirmations and the branch-delete warning. No console errors.
|
||||||
|
This closes the outstanding M3 condition recorded above and the equivalent M4
|
||||||
|
one.
|
||||||
|
|
||||||
|
**The three review findings were fixed during closeout:**
|
||||||
|
|
||||||
|
- **B-1** — the Save Point list was an N+1 that loaded whole `Action` rows,
|
||||||
|
narration included. It is now one bulk two-column coordinate query plus one
|
||||||
|
lineage: **53 SELECTs for 25 Save Points became 5**, and the query count no
|
||||||
|
longer moves with the length of the list.
|
||||||
|
- **B-2** — deleting a branch silently deleted the Save Points naming it. Fixed
|
||||||
|
as a **behaviour** defect, not a wording one: a branch a Save Point names can
|
||||||
|
no longer be deleted at all. The request is refused with the offending Save
|
||||||
|
Points named, the user deletes them explicitly (which deletes no story), and
|
||||||
|
the branch then goes. Both delete controls, in the branch list and in the tree
|
||||||
|
overlay, disable and explain rather than warning about a loss that no longer
|
||||||
|
happens. `STORY-BRANCH-SEMANTICS.md` **§19.1** records the rule; §28 already
|
||||||
|
required a future cleanup feature to retain checkpoint-referenced paths, and
|
||||||
|
this is that requirement applied to the deletion path that exists today.
|
||||||
|
- **B-3** — the D11/L03 automation now spawns real server processes.
|
||||||
|
|
||||||
|
**Also fixed:** creating a Save Point takes the campaign's turn lock (review §S
|
||||||
|
C-5), so "save where I am" cannot read a head that a turn in flight is about to
|
||||||
|
move. Rename and Delete deliberately do not take it — neither reads nor moves a
|
||||||
|
story position.
|
||||||
|
|
||||||
|
**Debt M4 carries forward:** the Save Point panel has no frontend test, because
|
||||||
|
the project still has no frontend test runner at all (M8) — the browser smoke
|
||||||
|
test above is a closeout procedure, not a suite. `POST /adventures/import` still
|
||||||
|
returns every branch's rows rather than a head-capped window (inherited, M3).
|
||||||
|
|
||||||
|
## Note to M5 — the instrumentation is now larger than M3 estimated
|
||||||
|
|
||||||
|
M4 added **60 tests that use inherited RPG world-state values as deterministic
|
||||||
|
instrumentation**, on top of the ~20 M3 flagged. M5 replaces that state model,
|
||||||
|
and must **move the instrumentation while preserving the behavioural
|
||||||
|
assertions**: what those tests measure is where the story is being read and what
|
||||||
|
state belongs to that position, which is exactly as true after M5 as before it.
|
||||||
|
Deleting them would delete the evidence for D11-D14, I04, L03 and the E-series.
|
||||||
|
|
||||||
|
The existing constraint stands and is now load-bearing for Save Points as well as
|
||||||
|
Undo/Redo: **historical state must remain efficiently snapshot/cache
|
||||||
|
recoverable**, so that moving to a position never becomes proportional to
|
||||||
|
campaign length (`TECHNICAL-DESIGN.md` §10.4). Restore, Undo and Redo all pay
|
||||||
|
whatever that costs.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# M5 — Genre-Neutral Authoritative Narrative State
|
# M5 — Genre-Neutral Authoritative Narrative State
|
||||||
|
|||||||
+65
-1
@@ -210,6 +210,66 @@ checkpoint:
|
|||||||
|
|
||||||
A checkpoint is a named pointer to a recoverable story position. It should normally remain tied to the turn where it was created. Restoring it moves the campaign active head; it does not delete later retained history. A new branch is created on the first divergent write after restore, not merely because the checkpoint was opened.
|
A checkpoint is a named pointer to a recoverable story position. It should normally remain tied to the turn where it was created. Restoring it moves the campaign active head; it does not delete later retained history. A new branch is created on the first divergent write after restore, not merely because the checkpoint was opened.
|
||||||
|
|
||||||
|
As implemented in M4, the pointer is a **coordinate rather than a turn id**:
|
||||||
|
`(branch_id, depth)`, which is the same pair §4 records as the campaign's active
|
||||||
|
head and which `head.node_at` resolves. This is the equivalence §4 already draws
|
||||||
|
between a turn reference and a branch-plus-depth, applied to the same position
|
||||||
|
from the other end, and it is not a shortcut — it is the more correct pointer of
|
||||||
|
the two for this data model:
|
||||||
|
|
||||||
|
- **A coordinate follows a retry; a row id does not.** One coordinate holds
|
||||||
|
every attempt at a turn and exactly one of them is live (§7). A Save Point
|
||||||
|
names the turn, so it must land on whichever take the story currently tells.
|
||||||
|
Pinning the row would leave the pointer on a superseded attempt the reader
|
||||||
|
cannot see.
|
||||||
|
- **The user-facing term is Save Point**; `checkpoint` remains the internal name
|
||||||
|
(`BROWSER-UX-SPEC.md` §23).
|
||||||
|
|
||||||
|
The row carries the name, an optional note, the coordinate, and its timestamps.
|
||||||
|
It carries **no** copy of the transcript, the state, the prompt, a memory, a
|
||||||
|
summary, or a branch's contents. Everything a restore produces comes from the
|
||||||
|
retained history the coordinate points into.
|
||||||
|
|
||||||
|
The retry case is what settles the coordinate-versus-turn-id question, and M4
|
||||||
|
closeout measured it rather than arguing it. A Save Point named a turn whose
|
||||||
|
live row was id 17; retrying that turn made id 17 dead and id 18 live at the
|
||||||
|
same coordinate; the Save Point resolved to id 18 and restored correctly. A row
|
||||||
|
id would have pinned a take the story no longer tells. **A Save Point names a
|
||||||
|
story position, not a particular take of it.**
|
||||||
|
|
||||||
|
Three further properties of the implemented model, recorded so they are decided
|
||||||
|
rather than incidental:
|
||||||
|
|
||||||
|
- **Names are not unique**, and nothing requires them to be. No product
|
||||||
|
requirement asks for uniqueness, and two names for one moment is a reasonable
|
||||||
|
thing for a player to want.
|
||||||
|
- **Several Save Points may name the same position.** Same reason.
|
||||||
|
- **Ordinary list presentation is newest-created first.** Story order is not
|
||||||
|
something the list can honestly claim: depths on lines that have parted
|
||||||
|
company are not comparable, so ordering by depth would draw a sequence that no
|
||||||
|
reading of the story passes through. When each was made is a fact about all of
|
||||||
|
them.
|
||||||
|
|
||||||
|
None of this is genre-specific. A coordinate is a position in a story; what the
|
||||||
|
state at that position *contains* is M5's question, and changing it does not
|
||||||
|
change what a Save Point is.
|
||||||
|
|
||||||
|
Two consequences worth recording here:
|
||||||
|
|
||||||
|
- **Restore reuses the campaign's one head-movement mechanism.** It resolves the
|
||||||
|
coordinate and moves the head; nothing is reconstructed and nothing is
|
||||||
|
deleted. The branch half of the head moves only when the coordinate is not on
|
||||||
|
the path being read, which is what makes a Save Point on a departed line
|
||||||
|
restorable at all — and what keeps a Save Point in a shared prefix from
|
||||||
|
dragging the reader off the line they chose. See `TECHNICAL-DESIGN.md` §8.8.
|
||||||
|
- **A checkpoint is durable against everything but its own explicit deletion.**
|
||||||
|
No pass removes one for going stale, sitting behind the head, or naming a line
|
||||||
|
the story left (`STORY-BRANCH-SEMANTICS.md` §19). Deleting a *branch* does not
|
||||||
|
remove one either: the deletion is refused while a checkpoint names any
|
||||||
|
position in the subtree, and the user deletes the checkpoint first
|
||||||
|
(§19.1). Deleting the whole campaign removes them, which is what deleting a
|
||||||
|
campaign means.
|
||||||
|
|
||||||
## 9. Narrative Entity
|
## 9. Narrative Entity
|
||||||
|
|
||||||
An entity is a persistent thing or concept in the fictional world.
|
An entity is a persistent thing or concept in the fictional world.
|
||||||
@@ -684,7 +744,11 @@ The physical container format remains an implementation choice, but the export m
|
|||||||
|
|
||||||
As implemented in M3, the export carries the active branch, the active head
|
As implemented in M3, the export carries the active branch, the active head
|
||||||
position on it, and each branch's disposition, alongside the whole retained turn
|
position on it, and each branch's disposition, alongside the whole retained turn
|
||||||
graph. The governing rule for this package is that an export carries what was
|
graph. **M4 added the checkpoints**, by the same rule: a position someone chose
|
||||||
|
to name cannot be recomputed from the turns, because nothing about a turn records
|
||||||
|
that it was bookmarked. The head and the checkpoints stay independent on import —
|
||||||
|
a campaign opens where its head says, never at a checkpoint merely because one is
|
||||||
|
in the file. The governing rule for this package is that an export carries what was
|
||||||
*chosen* and recomputes what is *derived* — and the active head moved from the
|
*chosen* and recomputes what is *derived* — and the active head moved from the
|
||||||
second category to the first, because once Undo stops deleting, two campaigns
|
second category to the first, because once Undo stops deleting, two campaigns
|
||||||
with identical turns can be being read at different positions and no import can
|
with identical turns can be being read at different positions and no import can
|
||||||
|
|||||||
@@ -82,16 +82,16 @@ in `planning/archive/decisions/`.
|
|||||||
One file, and it changes as development progresses:
|
One file, and it changes as development progresses:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
planning/reports/M3-IMPLEMENTATION-REPORT.md
|
planning/reports/M4-IMPLEMENTATION-REPORT.md
|
||||||
```
|
```
|
||||||
|
|
||||||
M3 is the most recently completed milestone, and M4 is the next to be briefed.
|
M4 is the most recently completed milestone, and M5 is the next to be briefed.
|
||||||
This report is M3's review *and* its primary evidence record — no separate M3
|
This report is M4's review *and* its closeout record: its §W holds the corrective
|
||||||
baseline report was produced — so it is the only place some of what M3 left
|
work, the process-boundary automation, and the real-browser verification that
|
||||||
behind is written down, including the browser smoke test M3 still owes.
|
closed the condition M3 and M4 both carried.
|
||||||
|
|
||||||
**Replace it, do not accumulate.** When M4's report lands, remove this one from
|
**Replace it, do not accumulate.** When M5's report lands, remove this one from
|
||||||
the project Sources and upload M4's instead. The repository does the same thing:
|
the project Sources and upload M5's instead. The repository does the same thing:
|
||||||
`planning/reports/` holds the current milestone's report and
|
`planning/reports/` holds the current milestone's report and
|
||||||
`planning/archive/milestone-reports/` holds the rest.
|
`planning/archive/milestone-reports/` holds the rest.
|
||||||
|
|
||||||
|
|||||||
+24
-22
@@ -3,9 +3,10 @@
|
|||||||
**This file is the index. Start here.**
|
**This file is the index. Start here.**
|
||||||
|
|
||||||
**Current state:** Phase 0 complete; AI-DnD forked as the production base;
|
**Current state:** Phase 0 complete; AI-DnD forked as the production base;
|
||||||
milestones **M1, M2 and M3 implemented and accepted** (M3: 2026-09-03).
|
milestones **M1, M2, M3 and M4 implemented and accepted** (M3 and M4:
|
||||||
**Next:** **M4 — named Save Points.** Its brief has not been written yet, and
|
2026-09-03).
|
||||||
writing it is the current action.
|
**Next:** **M5 — Genre-Neutral Authoritative Narrative State.** Its brief has not
|
||||||
|
been written yet, and writing it is the current action.
|
||||||
|
|
||||||
**Package version:** see `VERSION.md`, which records what each revision changed
|
**Package version:** see `VERSION.md`, which records what each revision changed
|
||||||
and why.
|
and why.
|
||||||
@@ -95,7 +96,7 @@ Two standing qualifications:
|
|||||||
10. `BROWSER-UX-SPEC.md`
|
10. `BROWSER-UX-SPEC.md`
|
||||||
11. `V1-ACCEPTANCE-TESTS.md`
|
11. `V1-ACCEPTANCE-TESTS.md`
|
||||||
12. `DECISIONS/` — all of them; they are short.
|
12. `DECISIONS/` — all of them; they are short.
|
||||||
13. `reports/M3-IMPLEMENTATION-REPORT.md`, for what the last milestone actually
|
13. `reports/M4-IMPLEMENTATION-REPORT.md`, for what the last milestone actually
|
||||||
left behind. Nothing in `planning/archive/` unless sent there.
|
left behind. Nothing in `planning/archive/` unless sent there.
|
||||||
|
|
||||||
## Architectural decisions
|
## Architectural decisions
|
||||||
@@ -126,13 +127,12 @@ work until Phase 0 closes — which Phase 0 satisfied on 2026-09-01. It is in
|
|||||||
`reports/` holds the report for the milestone most recently completed, because
|
`reports/` holds the report for the milestone most recently completed, because
|
||||||
that is the one the next milestone's planning has to consult:
|
that is the one the next milestone's planning has to consult:
|
||||||
|
|
||||||
- `reports/M3-IMPLEMENTATION-REPORT.md` — M3's review **and** its primary
|
- `reports/M4-IMPLEMENTATION-REPORT.md` — M4's review and its evidence record.
|
||||||
evidence record; no separate M3 baseline report was produced. M4 needs its
|
|
||||||
§W (closeout) and §M/§W.4 (the open browser smoke test).
|
|
||||||
|
|
||||||
Completed earlier milestones are in `archive/milestone-reports/`. When M4's
|
Completed earlier milestones are in `archive/milestone-reports/`, which M3's
|
||||||
report lands, M3's moves there too: a milestone report is useful during the
|
report joined when M4's landed: a milestone report is useful during the
|
||||||
immediate next milestone and historical afterwards.
|
immediate next milestone and historical afterwards. M3's is now at
|
||||||
|
`archive/milestone-reports/M3-IMPLEMENTATION-REPORT.md`, unedited.
|
||||||
|
|
||||||
## The decision this package rests on
|
## The decision this package rests on
|
||||||
|
|
||||||
@@ -230,13 +230,13 @@ Milestone M2 COMPLETE (2026-09-02)
|
|||||||
|
|
|
|
||||||
v
|
v
|
||||||
Milestone M3 COMPLETE (2026-09-03)
|
Milestone M3 COMPLETE (2026-09-03)
|
||||||
non-destructive undo/redo, reports/M3-IMPLEMENTATION-REPORT.md
|
non-destructive undo/redo, archive/milestone-reports/M3-*.md
|
||||||
active-head export and ADR 012
|
active-head export and ADR 012
|
||||||
|
|
|
|
||||||
v
|
v
|
||||||
Milestone M4 NEXT — brief not yet prepared
|
Milestone M4 COMPLETE (2026-09-03)
|
||||||
named Save Points
|
named Save Points reports/M4-IMPLEMENTATION-REPORT.md
|
||||||
|
|
| browser verification: PASS (M3 + M4)
|
||||||
v
|
v
|
||||||
M5-M11, one at a time see BUILD-MILESTONES.md
|
M5-M11, one at a time see BUILD-MILESTONES.md
|
||||||
```
|
```
|
||||||
@@ -245,15 +245,17 @@ M5-M11, one at a time see BUILD-MILESTONES.md
|
|||||||
|
|
||||||
**One milestone at a time. Do not begin a milestone before its brief exists.**
|
**One milestone at a time. Do not begin a milestone before its brief exists.**
|
||||||
|
|
||||||
**No M4 brief has been prepared.** Writing one is the current action, informed
|
**No M5 brief has been prepared.** Writing one is the current action, informed by
|
||||||
by the post-M3 corrections below, by the note `BUILD-MILESTONES.md` attaches to
|
the M4 report's §U readiness assessment and by the note `BUILD-MILESTONES.md`
|
||||||
M4, and by **ADR 012**, which records the head-movement mechanism M4 must reuse
|
attaches to M5 — in particular that M4 added 55 tests using the inherited
|
||||||
rather than reimplement.
|
world-state values as deterministic instrumentation, which M5 must **move rather
|
||||||
|
than delete**.
|
||||||
|
|
||||||
One M3 condition remains open and does not block M4: the required **browser
|
**No conditions remain open on M1-M4.** The browser smoke condition that M3 and
|
||||||
smoke test has not been performed**, because no session in which M3 was
|
M4 both carried was satisfied at M4 closeout: a real Firefox exercised the
|
||||||
implemented or reviewed had a browser available. See
|
rendered DOM for both milestones' controls, 44/44 checks passing. The M3 report's
|
||||||
`reports/M3-IMPLEMENTATION-REPORT.md` §M and §W.4.
|
§M.2 and the M4 report's §M record the condition as it stood; the M4 report's §W
|
||||||
|
records it closed.
|
||||||
|
|
||||||
## What each milestone closeout corrected
|
## What each milestone closeout corrected
|
||||||
|
|
||||||
|
|||||||
@@ -467,6 +467,39 @@ They should not be removed by:
|
|||||||
- branch divergence,
|
- branch divergence,
|
||||||
- ordinary history cleanup.
|
- ordinary history cleanup.
|
||||||
|
|
||||||
|
### 19.1 A checkpoint protects the history it names
|
||||||
|
|
||||||
|
Added at M4 closeout, from implementation evidence.
|
||||||
|
|
||||||
|
§19's durability rule is only meaningful if something enforces it against the
|
||||||
|
operations that delete history. Deleting a branch deletes that branch and
|
||||||
|
everything forked from it, so a checkpoint naming a position anywhere in that
|
||||||
|
subtree would go with it — and go silently, because the story is what the user
|
||||||
|
asked to delete and the named moments are not mentioned in the request.
|
||||||
|
|
||||||
|
Therefore:
|
||||||
|
|
||||||
|
- **A branch cannot be deleted while a checkpoint names a position on it, or on
|
||||||
|
any branch forked from it.** The deletion is refused, not amended: nothing is
|
||||||
|
half-deleted, and no checkpoint is quietly relocated or dropped.
|
||||||
|
- **The refusal names the checkpoints standing in the way**, so the user knows
|
||||||
|
what to act on rather than being told only that something is in the way.
|
||||||
|
- **The user resolves it by deleting the checkpoint explicitly**, which is
|
||||||
|
§19's "until explicitly deleted" being honoured rather than worked around.
|
||||||
|
Deleting a checkpoint still deletes no story (§25), so the recovery costs the
|
||||||
|
user nothing they wanted to keep.
|
||||||
|
- **The scope is the subtree**, not the named branch alone.
|
||||||
|
|
||||||
|
This is the same rule §28 already anticipates for a future cleanup feature —
|
||||||
|
*retain paths referenced by checkpoints* — applied to the one deletion path that
|
||||||
|
exists today. A cleanup or discarded-history feature added later must honour it
|
||||||
|
too, and must not acquire a way to delete a checkpoint as a side effect of
|
||||||
|
deleting history.
|
||||||
|
|
||||||
|
Campaign deletion is not an exception to this and needs no rule: deleting a
|
||||||
|
campaign deletes everything in it, checkpoints included, and that is what the
|
||||||
|
user asked for.
|
||||||
|
|
||||||
## 20. Checkpoint Restore
|
## 20. Checkpoint Restore
|
||||||
|
|
||||||
Restoring a checkpoint:
|
Restoring a checkpoint:
|
||||||
|
|||||||
@@ -413,6 +413,57 @@ than act silently, because retained history must not be made to disagree with
|
|||||||
itself in a way the user cannot see. See `STORY-BRANCH-SEMANTICS.md` §10 and
|
itself in a way the user cannot see. See `STORY-BRANCH-SEMANTICS.md` §10 and
|
||||||
§14A.
|
§14A.
|
||||||
|
|
||||||
|
### 8.8 Save Points, as implemented in M4
|
||||||
|
|
||||||
|
M4 added durable named Save Points and built nothing in §8 that was not already
|
||||||
|
there. This records what the milestone establishes as fact.
|
||||||
|
|
||||||
|
**A Save Point is a name and a coordinate.** The stored row holds the name, an
|
||||||
|
optional note, and `(branch, depth)` — the same pair §8.7 calls the head. It
|
||||||
|
holds no transcript, no state, no summary, no memory, and no branch contents.
|
||||||
|
`DATA-MODEL.md` §8 describes the pointer as naming a turn; the coordinate is
|
||||||
|
that turn's address, and `DATA-MODEL.md` §8's implementation note records why
|
||||||
|
this project uses the address rather than a row id: one coordinate can hold
|
||||||
|
several attempts at a turn, and a retry replaces the live one. "Turn 42 of this
|
||||||
|
line" survives a retry; a row id would pin a take the story no longer tells.
|
||||||
|
|
||||||
|
**Restore is head movement, and nothing else.** It resolves the coordinate,
|
||||||
|
refuses it if it no longer names a live turn, and then moves the head — the
|
||||||
|
depth through §8.7's single move operation, unchanged. The transcript, the
|
||||||
|
assembled context, the state and memory eligibility all arrive together because
|
||||||
|
they already read through the one capped lineage. There is no second restore
|
||||||
|
path, no state reconstruction, no memory pruning and no separate redo stack:
|
||||||
|
D13 is satisfied by the mechanism rather than by code written to satisfy it.
|
||||||
|
|
||||||
|
**A Save Point may name a position on a line the story has left.** Save Points
|
||||||
|
survive divergence, so this is reachable in ordinary use, and the depth half of
|
||||||
|
the head cannot reach a branch the current path does not contain. Restore
|
||||||
|
therefore moves the branch half as well when, and only when, the coordinate is
|
||||||
|
not on the path being read — the same single assignment a branch switch makes.
|
||||||
|
The distinction matters in the other direction too: a Save Point in a shared
|
||||||
|
prefix must *not* drag the reader onto the ancestor, because which continuation
|
||||||
|
follows that turn is exactly what the reader has already chosen.
|
||||||
|
|
||||||
|
**Restore never forks.** Moving the head is not a decision to abandon anything.
|
||||||
|
The first write below the restored head forks, through §8.7's existing check,
|
||||||
|
and the displaced future stays retained — so Redo still walks the original
|
||||||
|
continuation until the user writes something different, and stops offering it
|
||||||
|
once they have.
|
||||||
|
|
||||||
|
**Nothing removes a Save Point but the user.** There is no cleanup pass, and none
|
||||||
|
is wanted: a Save Point pointing behind the head, or into a line the story left,
|
||||||
|
is doing its job.
|
||||||
|
|
||||||
|
That rule is enforced against the one operation that could break it. Deleting a
|
||||||
|
branch deletes everything forked from it, so a Save Point naming a position in
|
||||||
|
that subtree would go too — silently, since the story is what the user asked to
|
||||||
|
delete. **The deletion is therefore refused while any Save Point names that
|
||||||
|
subtree**, and the refusal names them. The user deletes the Save Point
|
||||||
|
explicitly, which deletes no story, and then the branch. `STORY-BRANCH-SEMANTICS.md`
|
||||||
|
§19.1 states the rule; §28 already required a future cleanup feature to retain
|
||||||
|
paths a checkpoint references, and this is that requirement applied to the
|
||||||
|
deletion path that exists today.
|
||||||
|
|
||||||
## 9. Export / Import and Head Position
|
## 9. Export / Import and Head Position
|
||||||
|
|
||||||
AI-DnD's current export carries branch information but reconstructs the imported head at the branch tip.
|
AI-DnD's current export carries branch information but reconstructs the imported head at the branch tip.
|
||||||
@@ -460,6 +511,30 @@ Every row of an abandoned line is exported either way, so without that metadata
|
|||||||
restored campaign could not distinguish abandoned history from active history —
|
restored campaign could not distinguish abandoned history from active history —
|
||||||
which is precisely what a later cleanup or recovery feature has to select on.
|
which is precisely what a later cleanup or recovery feature has to select on.
|
||||||
|
|
||||||
|
### 9.2 Save Points in the bundle, as implemented in M4
|
||||||
|
|
||||||
|
Save Points are exported and imported with the campaign, which is `I04`. They
|
||||||
|
fall on the "chosen" side of §9.1's rule without argument: a position someone
|
||||||
|
named is not recoverable from the rows, since nothing about a turn records that
|
||||||
|
a player once bookmarked it.
|
||||||
|
|
||||||
|
No format version bump. A bundle written before M4 has no `checkpoints` key and
|
||||||
|
imports with none, which is what such a campaign had — the same unambiguous
|
||||||
|
absence §9.1 relies on for the head depth, and the same treatment the persona
|
||||||
|
block and the branch disposition received.
|
||||||
|
|
||||||
|
The head and the Save Points are independent, deliberately. An import opens the
|
||||||
|
campaign where `headDepth` says, never at a Save Point merely because the file
|
||||||
|
carries one: the bundle records where the story was being read and, separately,
|
||||||
|
which positions were named, and choosing between them is the user's to make
|
||||||
|
after the file is open.
|
||||||
|
|
||||||
|
A Save Point whose coordinate names no turn in the file is dropped rather than
|
||||||
|
refusing the import — the opposite of the head depth's treatment, and for a
|
||||||
|
stated reason. A misplaced head affects every read in the file; a bookmark
|
||||||
|
pointing outside the story affects only itself, and rejecting a whole campaign
|
||||||
|
to protect one bookmark would lose the story to save the pointer.
|
||||||
|
|
||||||
## 10. Authoritative Narrative State
|
## 10. Authoritative Narrative State
|
||||||
|
|
||||||
### 10.1 Do not retain the RPG state protocol as the product model
|
### 10.1 Do not retain the RPG state protocol as the product model
|
||||||
|
|||||||
@@ -1,8 +1,28 @@
|
|||||||
# Adventure Storyteller — V1 Acceptance Tests
|
# Adventure Storyteller — V1 Acceptance Tests
|
||||||
|
|
||||||
**Status:** v1.2 planning/release contract — updated after Phase 0B, after M2 for
|
**Status:** v1.3 planning/release contract — updated after Phase 0B, after M2 for
|
||||||
the security contract (H10 strengthened, H12 added), and after M3 for history
|
the security contract (H10 strengthened, H12 added), after M3 for history
|
||||||
ownership and results (D03, D10, I07, L01)
|
ownership and results (D03, D10, I07, L01), and after M4 for Save Point results
|
||||||
|
(D11-D14, I04, L03, E-series) and the browser condition below
|
||||||
|
|
||||||
|
> **Browser-level verification (M4 closeout, 2026-09-03).** The browser smoke
|
||||||
|
> condition that M3 and M4 both carried is **satisfied**. A real Firefox 154.0.1,
|
||||||
|
> driven through geckodriver over the W3C WebDriver protocol, exercised the
|
||||||
|
> rendered DOM: M3's Undo/Redo enable states, transcript movement, Retry and the
|
||||||
|
> take pager, divergence and the loss of Redo; and M4's full Save Point
|
||||||
|
> lifecycle including both confirmations and the branch-delete warning. 44/44
|
||||||
|
> checks passed with no console errors, on two independent runs. No pass
|
||||||
|
> condition anywhere in this document was changed to achieve it. See
|
||||||
|
> `reports/M4-IMPLEMENTATION-REPORT.md` §W.
|
||||||
|
>
|
||||||
|
> **Three kinds of evidence are recorded separately below, and are not
|
||||||
|
> interchangeable.** *Automated* means a test in the repository's suite, which
|
||||||
|
> runs on every future change. *Live runtime* means a real server exercised over
|
||||||
|
> HTTP — stronger than a unit test about process boundaries, weaker than a
|
||||||
|
> browser about anything a user sees. *Browser* means the rendered DOM driven by
|
||||||
|
> a real browser, which is the only evidence that a control is visible, enabled
|
||||||
|
> and wired. Where a result cites more than one, the strongest is named last.
|
||||||
|
|
||||||
**Purpose:** Define black-box acceptance tests for finalist evaluation during Phase 0B and for the eventual v1 release.
|
**Purpose:** Define black-box acceptance tests for finalist evaluation during Phase 0B and for the eventual v1 release.
|
||||||
|
|
||||||
## 1. Test Philosophy
|
## 1. Test Philosophy
|
||||||
@@ -737,6 +757,14 @@ Before entering the abbey
|
|||||||
### Pass
|
### Pass
|
||||||
Checkpoint persists across application restart.
|
Checkpoint persists across application restart.
|
||||||
|
|
||||||
|
### Result — PASS (M4 closeout, 2026-09-04)
|
||||||
|
*Automated:* `backend/tests/test_process_restart.py` starts the application as a
|
||||||
|
subprocess, writes the campaign, **terminates the process**, and starts a second
|
||||||
|
process against the same database — the Save Point, its name and its
|
||||||
|
`(branch, depth)` coordinate all survive.
|
||||||
|
*Browser:* the Save Point is still listed after a full page reload
|
||||||
|
(`reports/M4-IMPLEMENTATION-REPORT.md` §W.7 section E).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## D12 — Restore Checkpoint
|
## D12 — Restore Checkpoint
|
||||||
@@ -751,6 +779,11 @@ Checkpoint persists across application restart.
|
|||||||
### Pass
|
### Pass
|
||||||
Transcript/state return to checkpoint position.
|
Transcript/state return to checkpoint position.
|
||||||
|
|
||||||
|
### Result — PASS (M4 closeout, 2026-09-04)
|
||||||
|
*Automated:* `test_d12_restore_returns_the_transcript_and_the_state`.
|
||||||
|
*Browser:* the visible transcript and the state both move back, and the view
|
||||||
|
refreshes without a manual reload (§W.7 section F).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## D13 — Restore Does Not Delete Later History
|
## D13 — Restore Does Not Delete Later History
|
||||||
@@ -760,6 +793,13 @@ Transcript/state return to checkpoint position.
|
|||||||
### Pass
|
### Pass
|
||||||
Later story is retained as abandoned/disposable history.
|
Later story is retained as abandoned/disposable history.
|
||||||
|
|
||||||
|
### Result — PASS (M4 closeout, 2026-09-04)
|
||||||
|
*Automated:* measured on **row identity**, not on counts — the set of action row
|
||||||
|
ids before a restore equals the set after it. Ordinary Redo still walks the retained
|
||||||
|
continuation until a divergent write, and after that write the displaced rows are
|
||||||
|
still present while Redo reports nothing ahead. Four `test_d13_*` tests, and
|
||||||
|
confirmed in the browser with a database check behind it.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## D14 — Delete Checkpoint
|
## D14 — Delete Checkpoint
|
||||||
@@ -773,10 +813,31 @@ Delete named checkpoint.
|
|||||||
- checkpoint pointer disappears,
|
- checkpoint pointer disappears,
|
||||||
- referenced story turn/history remains intact.
|
- referenced story turn/history remains intact.
|
||||||
|
|
||||||
|
### Result — PASS (M4 closeout, 2026-09-04)
|
||||||
|
*Automated:* the pointer row goes; the referenced turn, the later history and the
|
||||||
|
active head are all unchanged (`test_d14_delete_removes_the_pointer_and_no_story`).
|
||||||
|
*Browser:* the confirmation states that deleting the Save Point does not delete
|
||||||
|
the story, and the story remains afterwards (§W.7 section I).
|
||||||
|
|
||||||
|
A Save Point is also the **only** thing that can remove itself: deleting a branch
|
||||||
|
whose history a Save Point names is refused rather than cascading
|
||||||
|
(`STORY-BRANCH-SEMANTICS.md` §19.1), verified automatically and in the browser.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# E. Branch and Lineage Safety
|
# E. Branch and Lineage Safety
|
||||||
|
|
||||||
|
> **M4 result (2026-09-03).** E01 and E04 were re-exercised through a Save Point
|
||||||
|
> restore rather than only through Undo, and pass: a memory derived past a
|
||||||
|
> restored head stops being retrievable and becomes eligible again on Redo,
|
||||||
|
> without being deleted or re-embedded; after restore-plus-divergence the old
|
||||||
|
> future's memory stays ineligible even as the new line grows past its depth; and
|
||||||
|
> the transcript after a restore holds only the active lineage while the
|
||||||
|
> displaced rows remain in the tree. **E03** (summary lineage over a long story)
|
||||||
|
> remains **NOT PERFORMED** — it needs a long-run campaign and is owned by
|
||||||
|
> M6/M11, unchanged from M3.
|
||||||
|
|
||||||
|
|
||||||
## E01 — Abandoned Future Cannot Affect Active State
|
## E01 — Abandoned Future Cannot Affect Active State
|
||||||
|
|
||||||
**Priority:** REQUIRED FOR V1
|
**Priority:** REQUIRED FOR V1
|
||||||
@@ -1353,6 +1414,15 @@ Export preserves retained alternate/disposable history needed for recovery, unle
|
|||||||
### Pass
|
### Pass
|
||||||
Named checkpoints survive export/import.
|
Named checkpoints survive export/import.
|
||||||
|
|
||||||
|
### Result — PASS (M4 closeout, 2026-09-04)
|
||||||
|
*Automated and live runtime.* A real round trip with three Save Points across two
|
||||||
|
branches: names, notes and
|
||||||
|
coordinates survive, branch references are remapped to the imported rows
|
||||||
|
(1→3, 2→4), and each restores to a distinct position and state in the new
|
||||||
|
campaign. Importing Save Points does **not** move the active head — the head
|
||||||
|
still comes from the bundle's `headDepth`. Bundles written before M4 carry no
|
||||||
|
`checkpoints` key, import cleanly, and create none.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## I05 — Knowledge Provenance Export
|
## I05 — Knowledge Provenance Export
|
||||||
@@ -1581,6 +1651,14 @@ State at each position matches original accepted state.
|
|||||||
### Pass
|
### Pass
|
||||||
Correct historical state is reconstructed.
|
Correct historical state is reconstructed.
|
||||||
|
|
||||||
|
### Result — PASS (M4 closeout, 2026-09-04)
|
||||||
|
*Automated, across a genuine OS process boundary:* the state at the Save Point
|
||||||
|
was recorded before the first process exited, and a second process restored
|
||||||
|
exactly that value after the campaign had been advanced past it
|
||||||
|
(`backend/tests/test_process_restart.py`). This replaced a same-process
|
||||||
|
`TestClient` restart, which could not distinguish durable state from a live
|
||||||
|
object.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## L04 — Derived Data Can Be Rebuilt
|
## L04 — Derived Data Can Be Rebuilt
|
||||||
|
|||||||
+122
-3
@@ -1,8 +1,127 @@
|
|||||||
# Planning Package Version
|
# Planning Package Version
|
||||||
|
|
||||||
- **Package:** Adventure Storyteller Planning Package v2.4
|
- **Package:** Adventure Storyteller Planning Package v2.6
|
||||||
- **Revision date:** 2026-09-03
|
- **Revision date:** 2026-09-03
|
||||||
- **Status:** Phase 0 complete; architecture selected; **Milestones M1, M2 and M3 implemented and accepted**; M4 is next to brief.
|
- **Status:** Phase 0 complete; architecture selected; **Milestones M1-M4 implemented and accepted**; M5 is next to brief.
|
||||||
|
|
||||||
|
## v2.6 — M4 Closeout (2026-09-03)
|
||||||
|
|
||||||
|
M4 is **accepted**. Its review returned *PASS WITH CORRECTIVE WORK REQUIRED*; the
|
||||||
|
corrective work is done, and the browser condition that M3 and M4 both carried is
|
||||||
|
closed.
|
||||||
|
|
||||||
|
**The three review findings, fixed:**
|
||||||
|
|
||||||
|
- **B-1 — the Save Point list was an N+1.** It resolved each Save Point with its
|
||||||
|
own query and loaded whole `Action` rows, narration included, to answer "does a
|
||||||
|
row exist here". It is now one bulk two-column coordinate query plus one
|
||||||
|
lineage computation: **53 SELECTs for 25 Save Points became 5**, and the count
|
||||||
|
no longer grows with the list. Guarded by three tests, including one proving
|
||||||
|
the coordinate is matched as a *pair* — an `IN`-list version would report a
|
||||||
|
Save Point resolved because another branch has a live row at the same depth.
|
||||||
|
- **B-2 — deleting a branch silently deleted its Save Points.** Fixed as a
|
||||||
|
**behaviour** defect rather than a missing warning, because
|
||||||
|
`STORY-BRANCH-SEMANTICS.md` §19 says a named checkpoint remains until
|
||||||
|
explicitly deleted and §28 already required future cleanup to retain
|
||||||
|
checkpoint-referenced paths. **A branch a Save Point names can no longer be
|
||||||
|
deleted.** The request is refused with the offending Save Points named; the
|
||||||
|
user deletes them explicitly, which deletes no story, and the branch then
|
||||||
|
goes. Both delete controls disable and explain. Recorded as a new
|
||||||
|
**§19.1**.
|
||||||
|
- **B-3 — the D11/L03 automation never left one process.** A new module spawns
|
||||||
|
real server processes, kills the first, and reads the campaign back with the
|
||||||
|
second.
|
||||||
|
|
||||||
|
**Also fixed (review §S C-5):** creating a Save Point now takes the campaign's
|
||||||
|
turn lock, so "save where I am" cannot read a head a turn in flight is about to
|
||||||
|
move. Rename and Delete deliberately do not take it, and a test pins that
|
||||||
|
decision.
|
||||||
|
|
||||||
|
**Real-browser verification — the first in this project.** A Firefox 154.0.1
|
||||||
|
driven through geckodriver over the W3C WebDriver protocol exercised the rendered
|
||||||
|
DOM for **both** milestones: **44/44 checks passed**, no console errors. It
|
||||||
|
covered M3's Undo/Redo enable states, transcript movement, Retry and the take
|
||||||
|
pager, and divergence retiring Redo; and M4's whole Save Point lifecycle
|
||||||
|
including both confirmations and the new branch-delete warning. **The outstanding
|
||||||
|
M3 browser condition is therefore closed as well.** No dependency was added to
|
||||||
|
the repository: the WebDriver client for the run was written against stdlib HTTP.
|
||||||
|
|
||||||
|
**Also corrected, found while fixing B-2:** `models.py`, `TECHNICAL-DESIGN.md`
|
||||||
|
§8.8 and `DATA-MODEL.md` §8 all described the cascade as the durability rule.
|
||||||
|
They now describe the refusal, and record that `checkpoints.branch_id`'s cascade
|
||||||
|
survives as referential integrity that the application no longer reaches.
|
||||||
|
|
||||||
|
**Documents corrected by this closeout:**
|
||||||
|
|
||||||
|
- `V1-ACCEPTANCE-TESTS.md` records results for **D11-D14, I04, L03** and the
|
||||||
|
E-series, and states that the browser-level condition is satisfied. **No pass
|
||||||
|
condition was weakened** — and D11/L03 now note that the automation crosses a
|
||||||
|
genuine OS process boundary, which is the standard later milestones should
|
||||||
|
meet.
|
||||||
|
- `DATA-MODEL.md` §8 records the coordinate as implemented, with the retry
|
||||||
|
measurement that settles coordinate-versus-turn-id, and three decisions that
|
||||||
|
were previously implicit: names are not unique, several Save Points may name
|
||||||
|
one position, and the list is newest-created first.
|
||||||
|
- `STORY-BRANCH-SEMANTICS.md` gains **§19.1** — a checkpoint protects the
|
||||||
|
history it names. This is the only behavioural specification change in the
|
||||||
|
closeout, and it strengthens §19 rather than weakening anything.
|
||||||
|
- `BROWSER-UX-SPEC.md` §25 rules for the implemented vocabulary: **Moment N**,
|
||||||
|
not *Turn N*, because the branch panel and tree overlay already count in
|
||||||
|
moments. Vocabulary only; no behaviour changes.
|
||||||
|
- `BUILD-MILESTONES.md` marks **M4 COMPLETE**, records the fixes and the browser
|
||||||
|
result, and warns M5 that the instrumentation to move is now **55 tests**.
|
||||||
|
- `README.md` records M1-M4 accepted and M5 as next to brief.
|
||||||
|
- `PROJECT-SOURCES.md` and `project-sources.txt` point at the current report;
|
||||||
|
`project-sources.txt` still named the archived M3 report and was corrected.
|
||||||
|
|
||||||
|
**Report rotation** happened in the reporting pass that preceded this closeout:
|
||||||
|
`M3-IMPLEMENTATION-REPORT.md` moved to `archive/milestone-reports/` as a pure
|
||||||
|
rename, and `reports/` now holds M4's report, whose **§W** is this closeout's
|
||||||
|
evidence record.
|
||||||
|
|
||||||
|
**No new ADR.** ADR 012 already decides the architecture, and the corrective work
|
||||||
|
forced no new architectural decision. `SPECIFICATION.md`,
|
||||||
|
`STORY-BRANCH-SEMANTICS.md`, `SECURITY-THREAT-MODEL.md`, `CONTEXT-AND-MEMORY.md`,
|
||||||
|
`IMPORTED-KNOWLEDGE-DESIGN.md` and ADRs 003, 005 and 012 are unchanged.
|
||||||
|
|
||||||
|
**M5 readiness:** ready. Save Points store no state and no checkpoint code reads
|
||||||
|
any, so M5 can change what a snapshot contains without touching what a Save Point
|
||||||
|
is — provided it keeps state recoverable at a position without replay
|
||||||
|
(`TECHNICAL-DESIGN.md` §10.4).
|
||||||
|
|
||||||
|
## v2.5 — M4 Implementation (2026-09-03)
|
||||||
|
|
||||||
|
M4 added durable named Save Points. This revision records only what the
|
||||||
|
implementation established as fact; **no product requirement changed**, and the
|
||||||
|
milestone is **not** marked accepted — its review has not been written.
|
||||||
|
|
||||||
|
- `TECHNICAL-DESIGN.md` gains **§8.8** and **§9.2**: the Save Point as a name
|
||||||
|
plus a coordinate holding no story, restore as head movement with a bounds
|
||||||
|
check, the rule that the branch half of the head moves only when the
|
||||||
|
coordinate is off the path being read, restore never forking, and the bundle
|
||||||
|
carrying Save Points independently of the head.
|
||||||
|
- `DATA-MODEL.md` **§8** records the pointer as implemented — `(branch, depth)`
|
||||||
|
rather than a turn id, with the reason: 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 superseded take. **§29** records the checkpoints in the export.
|
||||||
|
- `BUILD-MILESTONES.md` **M4** gains a status block: what shipped, the one
|
||||||
|
architectural decision the milestone had to make and why it needed no new ADR,
|
||||||
|
the test count, and the outstanding browser condition.
|
||||||
|
- `README.md` describes Save Points as a user-facing capability.
|
||||||
|
|
||||||
|
**No ADR was created.** ADR 012 already decides the architecture M4 needed —
|
||||||
|
restore reuses active-head movement — and a table is not a decision. The one
|
||||||
|
question ADR 012 does not answer, whether restore moves the branch half of the
|
||||||
|
head, is that same mechanism applied to a coordinate ADR 012 already defines;
|
||||||
|
`TECHNICAL-DESIGN.md` §8.8 records the answer rather than a new ADR asserting it.
|
||||||
|
|
||||||
|
`SPECIFICATION.md`, `SECURITY-THREAT-MODEL.md`, `STORY-BRANCH-SEMANTICS.md` and
|
||||||
|
`V1-ACCEPTANCE-TESTS.md` are unchanged. M4 altered no product requirement, added
|
||||||
|
no outbound path, and implemented the checkpoint semantics
|
||||||
|
`STORY-BRANCH-SEMANTICS.md` §18-25 already specified rather than amending them.
|
||||||
|
|
||||||
|
**The browser smoke test remains unperformed, now for both M3 and M4.** No
|
||||||
|
session has had a usable browser. See `BUILD-MILESTONES.md` M3 and M4.
|
||||||
|
|
||||||
## v2.4 — Documentation Consolidation (2026-09-03)
|
## v2.4 — Documentation Consolidation (2026-09-03)
|
||||||
|
|
||||||
@@ -43,7 +162,7 @@ tell authoritative material from evidence at a glance.
|
|||||||
## v2.3 — Post-M3 Closeout (2026-09-03)
|
## v2.3 — Post-M3 Closeout (2026-09-03)
|
||||||
|
|
||||||
M3 replaced destructive Undo with a stored active head. Its review is
|
M3 replaced destructive Undo with a stored active head. Its review is
|
||||||
`reports/M3-IMPLEMENTATION-REPORT.md`, which is also M3's primary evidence
|
`archive/milestone-reports/M3-IMPLEMENTATION-REPORT.md`, which is also M3's primary evidence
|
||||||
record — no separate baseline report was produced — and whose §W records this
|
record — no separate baseline report was produced — and whose §W records this
|
||||||
closeout.
|
closeout.
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,11 @@ Git history.
|
|||||||
### `milestone-reports/` — completed milestone evidence
|
### `milestone-reports/` — completed milestone evidence
|
||||||
|
|
||||||
`M1-BASELINE-REPORT.md`, `M1-IMPLEMENTATION-REPORT.md`,
|
`M1-BASELINE-REPORT.md`, `M1-IMPLEMENTATION-REPORT.md`,
|
||||||
`M2-BASELINE-REPORT.md`, `M2-IMPLEMENTATION-REPORT.md`.
|
`M2-BASELINE-REPORT.md`, `M2-IMPLEMENTATION-REPORT.md`,
|
||||||
|
`M3-IMPLEMENTATION-REPORT.md`.
|
||||||
|
|
||||||
|
M3's report is both its review and its primary evidence record; no separate M3
|
||||||
|
baseline report was produced. It arrived here when M4's report landed.
|
||||||
|
|
||||||
Every architectural conclusion these reports reached has already been applied to
|
Every architectural conclusion these reports reached has already been applied to
|
||||||
the active planning documents and the ADRs — see `planning/VERSION.md`, which
|
the active planning documents and the ADRs — see `planning/VERSION.md`, which
|
||||||
|
|||||||
@@ -26,4 +26,4 @@ planning/DECISIONS/009-ai-dnd-production-base.md
|
|||||||
planning/DECISIONS/010-explicit-typed-narrative-state-events.md
|
planning/DECISIONS/010-explicit-typed-narrative-state-events.md
|
||||||
planning/DECISIONS/011-local-inference-endpoint-policy.md
|
planning/DECISIONS/011-local-inference-endpoint-policy.md
|
||||||
planning/DECISIONS/012-active-head-non-destructive-history.md
|
planning/DECISIONS/012-active-head-non-destructive-history.md
|
||||||
planning/reports/M3-IMPLEMENTATION-REPORT.md
|
planning/reports/M4-IMPLEMENTATION-REPORT.md
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user