Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7005e6fdd | ||
|
|
62a997f364 | ||
|
|
279a871a77 | ||
|
|
e08d49c3eb | ||
|
|
3c8e91f644 |
+36
-5
@@ -91,15 +91,16 @@ decision that this project's threat model does not cover
|
||||
|
||||
## Pointing the storyteller at Ollama
|
||||
|
||||
The endpoint, model and (unused) API key are **runtime settings stored in the
|
||||
database**, not environment variables. Set them on the app's Settings page, or
|
||||
with one request:
|
||||
The endpoint, the model and the generation parameters are **runtime settings
|
||||
stored in the database**, not environment variables. There is no API key field:
|
||||
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
|
||||
curl -X PUT http://127.0.0.1:8000/api/settings \
|
||||
-H 'Content-Type: application/json' \
|
||||
-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}'
|
||||
```
|
||||
|
||||
@@ -208,7 +209,7 @@ visible from within.
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
cd backend && .venv/bin/python -m pytest tests/ -q # 638 tests
|
||||
cd backend && .venv/bin/python -m pytest tests/ -q # 756 tests
|
||||
cd frontend && npm run lint && npm run build
|
||||
```
|
||||
|
||||
@@ -224,6 +225,36 @@ suite as complete evidence.
|
||||
is lost from the union, or if a new HTTP client is added without the shared
|
||||
verification context.
|
||||
|
||||
M5 added `test_narrative_state.py`, which fails if the state stops being
|
||||
genre-neutral, if an event outside the allowlist is ever applied, if a malformed
|
||||
proposal mutates anything, if campaign canon stops outranking the narration, or
|
||||
if a turn's narration and its state can be committed apart from each other.
|
||||
|
||||
`test_narrative_realistic.py` is the one suite that needs a real model, and it is
|
||||
skipped unless you point it at one:
|
||||
|
||||
```bash
|
||||
AIDND_TEST_ENDPOINT=http://127.0.0.1:11434/v1 \
|
||||
AIDND_TEST_MODEL=qwen2.5:3b-instruct \
|
||||
backend/.venv/bin/python -m pytest backend/tests/test_narrative_realistic.py -v -s
|
||||
```
|
||||
|
||||
It exists because Phase 0B found that structured-state behaviour can look
|
||||
correct on a small prompt and fail under a full one — and it has already earned
|
||||
its place, catching a case where a model echoed its own instruction into the
|
||||
narration.
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
@@ -32,8 +32,10 @@ that isn't the live one starts a new branch.
|
||||
## Features
|
||||
|
||||
- **The full play loop.** Do / Say / Story / Continue actions, streamed AI responses (SSE),
|
||||
retry, undo, redo, and edit. Reasoning models are supported: "thinking" streams into a collapsible
|
||||
💭 panel with its own token budget.
|
||||
retry, undo, redo, and edit. Correcting narrator prose does not overwrite it: the correction
|
||||
becomes a new continuation carrying the state it implies, and the original narration keeps its
|
||||
own future as retained history. Reasoning models are supported: "thinking" streams into a
|
||||
collapsible 💭 panel with its own token budget.
|
||||
- **A branching story tree.** The story is a tree, not a list. Any turn can hold more than one
|
||||
**take**, and `‹ 2/4 ›` steps between them. Stepping is free: the story below simply empties,
|
||||
and the server is told nothing. Writing below a take that isn't the live one is what makes a
|
||||
@@ -42,13 +44,17 @@ that isn't the live one starts a new branch.
|
||||
line's world state, script state, and cooldown clocks. A branch panel switches, renames, and
|
||||
deletes; **⌗ See the tree** draws every line against the story's own clock
|
||||
(`backend/app/tree.py`, `backend/app/context/lineage.py`).
|
||||
- **An RPG world-state engine.** A scenario can declare stats, flags, milestones, and a named
|
||||
cast; the adventure carries their live values. The AI proposes deltas, and a Python engine
|
||||
referees them: it clamps values to range, enforces per-turn caps and cooldowns, keeps counters
|
||||
monotonic and milestones sticky, then strips the machine-readable block out of the prose
|
||||
(`backend/app/worldstate/`: `apply.py` clamps, `parse.py` reads the block back).
|
||||
Word-labeled bands (`40–60: minor damage`) make the
|
||||
model reliable at it. No dice and no scripting are required.
|
||||
- **Authoritative narrative state, and the application owns it.** The story tracks who exists,
|
||||
where they are, what they hold, what is true, how they are tied to each other, and what is
|
||||
still open — as generic entities, facts, relationships and threads, with no genre baked in.
|
||||
The same schema holds a silver key in an abbey and a data crystal on an orbital station.
|
||||
The AI proposes **typed events with absolute values** (`set_possession`, `add_fact`,
|
||||
`set_current_location` …), and a Python validator decides what is accepted: unknown event
|
||||
types are refused, references must resolve, campaign canon outranks the narration, and the
|
||||
machine-readable block never reaches the reader (`backend/app/narrative/`). Every accepted
|
||||
change is recorded with what it was before and which turn caused it, so the Story State panel
|
||||
can show what changed and why. You can correct it by hand, and your correction outranks the
|
||||
story.
|
||||
- **AI Dungeon-compatible context engine.** Memory, author's note, and story cards (world
|
||||
info) are triggered by keywords in recent story text, then assembled under a token budget
|
||||
(`backend/app/context/builder.py`).
|
||||
@@ -67,10 +73,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.
|
||||
Nothing a retry replaces is discarded either — the old attempt stays as another take of that
|
||||
turn, one keystroke and one click from becoming a branch of its own.
|
||||
- **Save Points.** Name a moment — "Before entering the abbey" — keep playing,
|
||||
restart the app, and come back to it. Restoring one moves the story back to
|
||||
that moment and deletes nothing: the turns you wrote after it stay, Redo still
|
||||
walks forward into them, and writing something different from the Save Point
|
||||
is what starts a new line while the old one is kept. A Save Point is a name for
|
||||
a position and holds no copy of the story, so restoring it is the same
|
||||
movement Undo makes (`backend/app/routers/adventures/checkpoints.py`,
|
||||
`backend/app/head.py`). They last until *you* delete them: 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:
|
||||
every branch, every take, the fork points, which branches the story has left behind, and the
|
||||
position it is being read at — all of them chosen rather than computed, which is the rule for
|
||||
what a bundle carries. A campaign exported after two Undos imports still undone, with its
|
||||
every branch, every take, the fork points, which branches the story has left behind, the Save
|
||||
Points and the position it is being read at — all of them chosen rather than computed, which is
|
||||
the rule for what a bundle carries. A campaign opens where its head says, never at a Save Point
|
||||
merely because it has one. A campaign exported after two Undos imports still undone, with its
|
||||
retained future intact, instead of silently reopening at its newest turn. Files that predate
|
||||
the head position, and files saved in the old single-line format, still import.
|
||||
- **Single user, no accounts.** There is no sign-up, no login, no session and no API key
|
||||
@@ -88,8 +106,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,
|
||||
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
|
||||
are taken when the browser smoke test M3 still owes is run.
|
||||
removed rather than left standing as a picture of a product that no longer exists. The M4
|
||||
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
|
||||
|
||||
@@ -121,18 +140,23 @@ Open http://localhost:5173.
|
||||
|
||||
## 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 |
|
||||
| Ollama, a machine on your own network | `http://<host>:11434/v1` or `https://<host>/v1` | see below |
|
||||
| LM Studio, same machine | `http://localhost:1234/v1` | |
|
||||
| The same machine | `http://localhost:11434/v1` | the default, and the simplest thing that works |
|
||||
| A machine on your own network | `http://<host>:11434/v1` or `https://<host>/v1` | explicitly configured; see below |
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
The address is checked when you save it and again before every request. Only loopback and
|
||||
@@ -146,7 +170,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
|
||||
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`
|
||||
backed by a command-line tool, which is useful for testing the turn engine against a stronger
|
||||
@@ -191,9 +215,11 @@ frontend/ React + Vite SPA ──HTTP/SSE──► backend/ FastAPI
|
||||
├─ tlstrust.py one TLS context: the OS trust store unioned with certifi's
|
||||
├─ tree.py forking, promotion, and where a node is placed
|
||||
├─ head.py the active head: where the story is read, and what moving it costs
|
||||
├─ checkpoints Save Points: durable names for positions, in routers/adventures/
|
||||
├─ attempts.py the takes of one turn, grouped by parent
|
||||
├─ context/ prompt assembly under a token budget + lineage/history windowing
|
||||
├─ worldstate/ the stat engine: clamps, cooldowns, bands, milestones
|
||||
├─ narrative/ the authoritative state: typed events, validation, snapshots
|
||||
├─ worldstate/ the inherited RPG stat engine — legacy, no longer authoritative
|
||||
├─ memorybank.py auto-summarization + embedding retrieval
|
||||
├─ bundle.py the export/import formats, v2 (tree) and a v1 reader
|
||||
├─ providers/ OpenAI-compatible adapter, streaming
|
||||
@@ -205,7 +231,7 @@ development, Vite proxies `/api` to FastAPI.
|
||||
|
||||
## Tests
|
||||
|
||||
638 backend tests: unit tests plus full HTTP integration through the real turn engine, with
|
||||
756 backend tests: unit tests plus full HTTP integration through the real turn engine, with
|
||||
the model provider mocked. They run with no route to the Internet, which is a requirement
|
||||
rather than a convenience — an offline claim proved on a machine that has been online once
|
||||
proves nothing.
|
||||
|
||||
+49
-7
@@ -38,6 +38,7 @@ from sqlalchemy.orm import Session, undefer
|
||||
|
||||
from . import models
|
||||
from .context import lineage
|
||||
from .narrative import model as narrative_model
|
||||
|
||||
# The slices of a context snapshot that belong to one attempt rather than to the
|
||||
# turn. They are the world-state delta the attempt proposed and what the engine
|
||||
@@ -45,7 +46,7 @@ from .context import lineage
|
||||
# token accounting. Each attempt is its own API call, and a retry is the call
|
||||
# most likely to read the prompt back out of cache. Everything else in a snapshot
|
||||
# is the prompt, which is assembled once per turn.
|
||||
ATTEMPT_KEYS = ("world_state", "raw_output", "usage")
|
||||
ATTEMPT_KEYS = ("world_state", "narrative_state", "raw_output", "usage")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ reading
|
||||
@@ -152,26 +153,67 @@ def preceding(
|
||||
# ------------------------------------------------------------------ writing
|
||||
|
||||
def restore_state(adventure: models.Adventure, node: models.Action | None) -> None:
|
||||
"""Restores the world state that `node` left behind.
|
||||
"""Restores the state that `node` left behind.
|
||||
|
||||
A NULL snapshot means leave the live state as it is, never reset it. Rows
|
||||
written before SP4 that the migration could not derive an outcome for carry
|
||||
NULLs, and overwriting a running adventure's state with an empty dict would
|
||||
be worse than doing nothing.
|
||||
This is what makes Undo, Redo, a branch switch and a Save Point restore cost
|
||||
the same at any distance: the destination node carries its own outcome, so
|
||||
arriving is a row read rather than a replay (`TECHNICAL-DESIGN.md` §10.4).
|
||||
M5 changed what is restored, not how — the narrative state document takes
|
||||
the place the RPG world state held, through the same single function.
|
||||
|
||||
The two columns follow **different** rules about a NULL, and the difference
|
||||
is not an oversight.
|
||||
|
||||
For the narrative document, a NULL means *this position established
|
||||
nothing*, and it is restored as the empty document. Leaving the live state
|
||||
alone instead is what the M5 review caught (Finding 3): arriving at a
|
||||
migrated pre-M5 node left a later position's entities, facts and threads
|
||||
standing, so the transcript said depth 2 while the state described depth 6.
|
||||
The invariant this module exists to hold is that the visible position, the
|
||||
head and the authoritative state agree, and "keep whatever was there" cannot
|
||||
hold it. An empty document at an old position is honest — the narrative
|
||||
state system knew nothing then, because it did not exist — where retained
|
||||
state from elsewhere is a claim about a story that had not been told yet.
|
||||
|
||||
Migration backfills those rows explicitly, so this fallback is the belt to
|
||||
that pair of braces: it also covers a node arriving from an older export,
|
||||
which the migration never sees.
|
||||
|
||||
For the legacy RPG world state a NULL still means leave it alone. Those rows
|
||||
predate SP4, nothing consults the values to decide anything, and overwriting
|
||||
a running adventure's numbers with an empty dict would be worse than doing
|
||||
nothing.
|
||||
"""
|
||||
if node is None:
|
||||
return
|
||||
adventure.narrative_state = (
|
||||
copy.deepcopy(node.narrative_state_after)
|
||||
if isinstance(node.narrative_state_after, dict)
|
||||
else narrative_model.empty()
|
||||
)
|
||||
# Legacy, and deliberately still restored: a pre-M5 campaign's numbers stay
|
||||
# coherent with the position being read, so an old save is not left showing
|
||||
# a future's values. Nothing consults them to decide anything.
|
||||
if isinstance(node.world_state_after, dict):
|
||||
adventure.world_state = copy.deepcopy(node.world_state_after)
|
||||
|
||||
|
||||
def snapshot_outcome(adventure: models.Adventure, node: models.Action) -> None:
|
||||
"""Records on `node` the state of the adventure now that the node has played."""
|
||||
"""Records on `node` the state of the adventure now that the node has played.
|
||||
|
||||
Every node, including a player's action that changed nothing. A position
|
||||
without a snapshot is a position the head cannot be restored to, and the
|
||||
head can rest on any node.
|
||||
"""
|
||||
world = adventure.world_state if isinstance(adventure.world_state, dict) else {}
|
||||
# `state_after` held the scripting engine's shared state, which M2 removed.
|
||||
# The column stays for schema compatibility and is written empty.
|
||||
node.state_after = {}
|
||||
node.world_state_after = copy.deepcopy(world)
|
||||
narrative = adventure.narrative_state
|
||||
node.narrative_state_after = copy.deepcopy(
|
||||
narrative if isinstance(narrative, dict) else narrative_model.empty()
|
||||
)
|
||||
|
||||
|
||||
def roll_back_before(
|
||||
|
||||
@@ -60,6 +60,7 @@ from sqlalchemy.orm import Session, undefer
|
||||
|
||||
from . import attempts, models, schemas
|
||||
from .context import cursors, lineage
|
||||
from .narrative import model as narrative_model
|
||||
|
||||
FORMAT = "ai-dnd-adventure-v2"
|
||||
LEGACY_FORMAT = "ai-dnd-adventure-v1"
|
||||
@@ -121,6 +122,14 @@ def export(db: Session, adventure: models.Adventure) -> dict:
|
||||
"desc": adventure.persona_desc,
|
||||
},
|
||||
"worldState": adventure.world_state,
|
||||
# M5. The authoritative narrative state, and the campaign's own rules.
|
||||
# Both are decisions rather than derivations — the state is what the
|
||||
# campaign established, and canon is what its owner wrote — so both go
|
||||
# in the file by the rule at the top of this module. A bundle written
|
||||
# before M5 has neither key and imports with an empty state, which is
|
||||
# what such a campaign had.
|
||||
"narrativeState": adventure.narrative_state,
|
||||
"campaignCanon": adventure.campaign_canon,
|
||||
"autoSummarize": adventure.auto_summarize,
|
||||
"memoryBankEnabled": adventure.memory_bank_enabled,
|
||||
# Write a root entry even for an adventure whose branch row was never
|
||||
@@ -134,6 +143,16 @@ def export(db: Session, adventure: models.Adventure) -> dict:
|
||||
"memoryCursor": _exported_anchor(adventure, cursors.MEMORY, local),
|
||||
"summaryCursor": _exported_anchor(adventure, cursors.SUMMARY, local),
|
||||
"memories": [_exported_memory(m, local) for m in adventure.memories],
|
||||
# M4. A Save Point is a decision — someone chose this position and gave
|
||||
# it a name — so it goes in the file by the rule at the top of this
|
||||
# module. Nothing here is derived: the coordinate is the one stored, not
|
||||
# one recomputed from the rows, because the whole point of the pointer
|
||||
# is that no amount of reading the turns can tell you which one somebody
|
||||
# named. A bundle written before M4 has no key here and imports with no
|
||||
# Save Points, which is what such a campaign had.
|
||||
"checkpoints": [
|
||||
_exported_checkpoint(c, local) for c in _checkpoints_of(db, adventure)
|
||||
],
|
||||
"storyCards": [
|
||||
{"type": c.type, "name": c.name, "keys": c.keys,
|
||||
"entry": c.entry, "notes": c.notes}
|
||||
@@ -197,6 +216,14 @@ def _exported_node(action: models.Action, local: dict[int, int]) -> dict:
|
||||
node["stateAfter"] = action.state_after
|
||||
if action.world_state_after is not None:
|
||||
node["worldStateAfter"] = action.world_state_after
|
||||
# M5. Without this a restored campaign could be read but not moved around
|
||||
# inside: every Undo, Redo and Save Point restore reads the destination
|
||||
# node's snapshot, so a bundle carrying the turns and not the snapshots
|
||||
# imports a story whose history cannot be walked.
|
||||
if action.narrative_state_after is not None:
|
||||
node["narrativeStateAfter"] = action.narrative_state_after
|
||||
if action.state_changes:
|
||||
node["stateChanges"] = action.state_changes
|
||||
if action.world_delta:
|
||||
node["worldDelta"] = action.world_delta
|
||||
return node
|
||||
@@ -215,6 +242,35 @@ def _exported_memory(memory: models.Memory, local: dict[int, int]) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _checkpoints_of(db: Session, adventure: models.Adventure) -> list[models.Checkpoint]:
|
||||
"""Returns the campaign's Save Points in creation order.
|
||||
|
||||
Read with a query rather than through a relationship, for the reason
|
||||
`models.Branch` declares none: a relationship on `Adventure` would be loaded
|
||||
by anything that touches an adventure, and the export is the only thing in
|
||||
the application that wants every Save Point at once.
|
||||
"""
|
||||
return (
|
||||
db.query(models.Checkpoint)
|
||||
.filter(models.Checkpoint.adventure_id == adventure.id)
|
||||
.order_by(models.Checkpoint.id)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def _exported_checkpoint(checkpoint: models.Checkpoint, local: dict[int, int]) -> dict:
|
||||
return {
|
||||
"name": checkpoint.name,
|
||||
"note": checkpoint.note,
|
||||
# The branch as a position in this file's list, like every other branch
|
||||
# reference in the bundle. The depth is a coordinate along it and needs
|
||||
# no translation.
|
||||
"branch": _local(checkpoint.branch_id, local),
|
||||
"depth": checkpoint.depth,
|
||||
"createdAt": checkpoint.created_at.isoformat() if checkpoint.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _imported_persona(persona) -> dict:
|
||||
"""Reads a bundle's `persona` block into `Adventure` keyword arguments.
|
||||
|
||||
@@ -280,6 +336,13 @@ def plan(bundle: dict, version: str) -> dict:
|
||||
"branches": branches,
|
||||
"nodes": nodes,
|
||||
"memories": _planned_memories(bundle, len(branches)),
|
||||
# M4. Empty for a version 1 bundle and for any version 2 bundle written
|
||||
# before Save Points existed, which is the same answer: no one had named
|
||||
# a position in those campaigns.
|
||||
"checkpoints": (
|
||||
_planned_checkpoints(bundle, len(branches), nodes)
|
||||
if version == FORMAT else []
|
||||
),
|
||||
"head": head,
|
||||
# None means the file does not say, which is every version 1 bundle and
|
||||
# every version 2 bundle written before M3. `_point_the_head` derives it
|
||||
@@ -449,6 +512,8 @@ def _planned_nodes(bundle: dict, branches: int) -> list[dict]:
|
||||
"branch": branch,
|
||||
"depth": depth,
|
||||
"live": bool(entry.get("live", True)),
|
||||
"narrativeStateAfter": _as_dict(entry.get("narrativeStateAfter")),
|
||||
"stateChanges": _as_dict(entry.get("stateChanges")),
|
||||
"type": str(entry.get("type") or "story")[:TYPE_MAX],
|
||||
"text": str(entry.get("text") or ""),
|
||||
"reasoning": _as_text(entry.get("reasoning")),
|
||||
@@ -525,6 +590,57 @@ def _planned_memories(bundle: dict, branches: int) -> list[dict]:
|
||||
return out
|
||||
|
||||
|
||||
def _planned_checkpoints(
|
||||
bundle: dict, branches: int, nodes: list[dict]
|
||||
) -> list[dict]:
|
||||
"""Returns the file's Save Points, checked against the tree it also carries.
|
||||
|
||||
A Save Point whose coordinate names no turn in the file is dropped rather
|
||||
than imported, and dropped rather than raising. The two halves of that are
|
||||
each deliberate:
|
||||
|
||||
* Dropped, because an imported pointer to a position the imported story does
|
||||
not contain is a Save Point that can only ever refuse to restore. It would
|
||||
be a row that exists to disappoint.
|
||||
* Not a 400, unlike the head depth. The head is a position the campaign is
|
||||
read at, so a file that misplaces it opens the story in the wrong place
|
||||
and every read is affected. A Save Point is a bookmark, and a bad one
|
||||
spoils nothing else in the file — refusing to import a whole campaign
|
||||
because one bookmark is wrong would lose the story to save the bookmark.
|
||||
|
||||
A name that is blank once trimmed is dropped for the same reason the create
|
||||
endpoint refuses one: an unnamed Save Point is not identifiable in a list.
|
||||
"""
|
||||
# Every coordinate the file writes, not only the ones it marks live.
|
||||
# `_write_nodes` makes exactly one attempt at each coordinate live whatever
|
||||
# the file says, so a coordinate that exists is a coordinate that will
|
||||
# resolve — and reading the flags here would drop a Save Point over a
|
||||
# question the writer has already settled.
|
||||
written = {(n["branch"], n["depth"]) for n in nodes}
|
||||
raw = bundle.get("checkpoints")
|
||||
out: list[dict] = []
|
||||
for entry in raw if isinstance(raw, list) else []:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
name = str(entry.get("name") or "").strip()[:schemas.CHECKPOINT_NAME_MAX]
|
||||
if not name:
|
||||
continue
|
||||
depth = entry.get("depth")
|
||||
if not _is_int(depth):
|
||||
continue
|
||||
branch = _as_index(entry.get("branch"), branches, default=None)
|
||||
if branch is None or (branch, depth) not in written:
|
||||
continue
|
||||
out.append({
|
||||
"name": name,
|
||||
"note": str(entry.get("note") or ""),
|
||||
"branch": branch,
|
||||
"depth": depth,
|
||||
"createdAt": _as_time(entry.get("createdAt")),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _planned_anchors(bundle: dict, branches: int) -> dict:
|
||||
anchors = {}
|
||||
for name in ("memory", "summary"):
|
||||
@@ -550,6 +666,7 @@ def write(db: Session, adventure: models.Adventure, story: dict) -> None:
|
||||
_write_nodes(db, adventure, story["nodes"], ids)
|
||||
_write_memories(db, adventure, story["memories"], ids)
|
||||
_point_the_head(adventure, story, ids)
|
||||
_write_checkpoints(db, adventure, story["checkpoints"], ids)
|
||||
_write_anchors(adventure, story, ids)
|
||||
|
||||
|
||||
@@ -620,6 +737,8 @@ def _write_nodes(
|
||||
state_after=spec["stateAfter"],
|
||||
world_state_after=spec["worldStateAfter"],
|
||||
world_delta=spec["worldDelta"],
|
||||
narrative_state_after=spec.get("narrativeStateAfter"),
|
||||
state_changes=spec.get("stateChanges"),
|
||||
)
|
||||
if spec["createdAt"] is not None:
|
||||
action.created_at = spec["createdAt"]
|
||||
@@ -665,6 +784,32 @@ def _write_memories(
|
||||
db.add(memory)
|
||||
|
||||
|
||||
def _write_checkpoints(
|
||||
db: Session, adventure: models.Adventure, specs: list[dict], ids: list[int]
|
||||
) -> None:
|
||||
"""Writes the Save Points, and moves nothing.
|
||||
|
||||
Note what this function does not touch. The head is pointed by
|
||||
`_point_the_head` from the file's own `headBranch`/`headDepth`, and importing
|
||||
a Save Point must not disturb it — a campaign exported at turn 30 with a
|
||||
Save Point at turn 12 opens at turn 30. The bundle records where the story
|
||||
was being read and, separately, which positions someone named; restoring one
|
||||
of them is a thing the user does afterwards, not a thing an import does for
|
||||
them.
|
||||
"""
|
||||
for spec in specs:
|
||||
checkpoint = models.Checkpoint(
|
||||
adventure_id=adventure.id,
|
||||
name=spec["name"],
|
||||
note=spec["note"],
|
||||
branch_id=ids[spec["branch"]],
|
||||
depth=spec["depth"],
|
||||
)
|
||||
if spec["createdAt"] is not None:
|
||||
checkpoint.created_at = spec["createdAt"]
|
||||
db.add(checkpoint)
|
||||
|
||||
|
||||
def _point_the_head(
|
||||
adventure: models.Adventure, story: dict, ids: list[int]
|
||||
) -> None:
|
||||
@@ -747,6 +892,13 @@ def materialize(
|
||||
ai_instructions=str(payload.get("aiInstructions") or ""),
|
||||
story_summary=str(payload.get("storySummary") or ""),
|
||||
world_state=payload.get("worldState") or {},
|
||||
# M5. Normalised on the way in, so a hand-edited or truncated state
|
||||
# section costs the section rather than the campaign — the story is the
|
||||
# valuable thing, and a malformed document should not refuse an import.
|
||||
narrative_state=narrative_model.normalize(payload.get("narrativeState"))
|
||||
if isinstance(payload.get("narrativeState"), dict) else None,
|
||||
campaign_canon=payload.get("campaignCanon")
|
||||
if isinstance(payload.get("campaignCanon"), dict) else None,
|
||||
auto_summarize=bool(payload.get("autoSummarize", False)),
|
||||
memory_bank_enabled=bool(payload.get("memoryBankEnabled", False)),
|
||||
**_imported_persona(payload.get("persona")),
|
||||
|
||||
@@ -22,7 +22,7 @@ from dataclasses import dataclass
|
||||
|
||||
import tiktoken
|
||||
|
||||
from .. import models, worldstate
|
||||
from .. import models, narrative, worldstate
|
||||
from . import encoding, history
|
||||
|
||||
AUTHORS_NOTE_DEPTH = 3 # actions from the end of history
|
||||
@@ -88,7 +88,7 @@ class Section:
|
||||
return count_tokens(self.text)
|
||||
|
||||
|
||||
def length_hint(max_output_tokens: int, *, has_ws: bool) -> str:
|
||||
def length_hint(max_output_tokens: int) -> str:
|
||||
"""Ask for a turn that fits inside the output cap, stated as a word budget.
|
||||
|
||||
Returns an empty string when the cap is too small to state usefully. The
|
||||
@@ -98,12 +98,8 @@ def length_hint(max_output_tokens: int, *, has_ws: bool) -> str:
|
||||
words = int((max_output_tokens - LENGTH_HEADROOM) * WORDS_PER_TOKEN * LENGTH_BUFFER)
|
||||
if words < MIN_LENGTH_HINT_WORDS:
|
||||
return ""
|
||||
tail = (
|
||||
" Finish the narration and append the state block well inside the limit."
|
||||
if has_ws
|
||||
else " Bring the turn to a close well inside the limit rather than "
|
||||
"stopping mid-sentence."
|
||||
)
|
||||
tail = " Finish the narration and append the state block well inside the limit."
|
||||
|
||||
# State the number as a ceiling, never as a budget. In measurements, the
|
||||
# wording "keep this turn under about N words" read to the model as a target
|
||||
# to fill. It raised the average from 174 words to 246 across five runs, and
|
||||
@@ -166,30 +162,58 @@ def _script_memory(adventure: models.Adventure) -> dict:
|
||||
def _history_text(action: models.Action) -> str:
|
||||
"""Returns an AI turn as the model should see it in replayed history.
|
||||
|
||||
The result is the narration with its state block appended again,
|
||||
reconstructed from the stored delta. The app strips that block before
|
||||
storing and displaying the turn. Without this function, every past AI turn
|
||||
would appear to have emitted no state, and the model would copy that pattern
|
||||
and stop emitting state itself. Player turns and turns with no block pass
|
||||
through unchanged.
|
||||
Replayed history is **prose only**. The protocol block is not reconstructed
|
||||
into it, and the M5 corrective pass is why (review Finding 4).
|
||||
|
||||
The block replays the changes the engine ACCEPTED, not the ones the model
|
||||
sent. Replaying what was sent showed the model a refused change standing as
|
||||
though it had been applied, while the live values in the same prompt
|
||||
disagreed with it. Nothing marked which of the two was true, so the model
|
||||
read its own refused change as correct and sent it again.
|
||||
Replaying the block was meant to teach the model the output format by
|
||||
example. What it actually did was put a second, older account of the world
|
||||
into the same prompt as the authoritative one, with nothing marking which
|
||||
governed. A fact the reader had explicitly withdrawn through a manual
|
||||
correction was dropped from the state section and then handed straight back
|
||||
in the history section, as an accepted event, phrased exactly as the model
|
||||
had first asserted it. C04 requires a correction to reach the narrator's
|
||||
context; a correction the next prompt contradicts has not reached it.
|
||||
|
||||
This function reads `world_delta` rather than `context_snapshot`. It runs
|
||||
for every action in the replayed history, and `context_snapshot` is deferred
|
||||
so that a turn never loads the prompt archive from the database.
|
||||
Two other things were wrong with it. The blocks are implementation
|
||||
metadata, not story, and every other consumer of stored text — memory,
|
||||
summaries, export, the transcript — treats an action's text as prose. And a
|
||||
turn's accepted events are a record of what was true *then*, which is
|
||||
precisely what a later correction, retcon or invalidation revises.
|
||||
|
||||
The format instruction survives without the examples: `EMIT_RULE` carries a
|
||||
worked example in the system block and `EMIT_REMINDER` repeats the demand
|
||||
last, where recency is strongest.
|
||||
"""
|
||||
text = action.text
|
||||
wd = action.world_delta if isinstance(action.world_delta, dict) else None
|
||||
if wd:
|
||||
block = worldstate.render_delta_block(worldstate.applied_delta(wd))
|
||||
if block:
|
||||
text = f"{text}\n{block}"
|
||||
return text
|
||||
return action.text
|
||||
|
||||
|
||||
def _canon_section(adventure: models.Adventure) -> str:
|
||||
"""The campaign's own rules, rendered for the system block.
|
||||
|
||||
Canon is configuration (C01, J03): the campaign writes what is true and what
|
||||
is forbidden, and both the prompt and the validator read the same field.
|
||||
Putting it in the system block is what makes C01 a narration-time constraint
|
||||
as well as a validation-time one — the model is told the rule rather than
|
||||
only refused after breaking it.
|
||||
"""
|
||||
canon = adventure.campaign_canon
|
||||
if not isinstance(canon, dict):
|
||||
return ""
|
||||
lines: list[str] = []
|
||||
rules = canon.get("rules")
|
||||
if isinstance(rules, list):
|
||||
lines += [f"- {rule}" for rule in rules if isinstance(rule, str) and rule.strip()]
|
||||
forbidden = canon.get("forbidden_status_changes")
|
||||
if isinstance(forbidden, list):
|
||||
for rule in forbidden:
|
||||
if isinstance(rule, dict) and rule.get("from") and rule.get("to"):
|
||||
lines.append(
|
||||
f"- Nothing that is {rule['from']} can become {rule['to']}."
|
||||
)
|
||||
if not lines:
|
||||
return ""
|
||||
body = "\n".join(lines)
|
||||
return f"Campaign canon (these are true and may not be contradicted):\n{body}"
|
||||
|
||||
|
||||
def _visible_npcs(actions: list[models.Action], stat_schema: dict) -> dict[str, str]:
|
||||
@@ -259,11 +283,14 @@ def build_context(
|
||||
stat_schema = adventure.scenario.stat_schema if adventure.scenario else None
|
||||
has_ws = worldstate.has_schema(stat_schema)
|
||||
persona_name = adventure.persona_name.strip()
|
||||
if has_ws:
|
||||
guide = worldstate.render_reference(stat_schema, persona_name)
|
||||
if guide:
|
||||
system_sections.append(Section("world_state_guide", guide))
|
||||
system_sections.append(Section("world_state_rule", worldstate.EMIT_RULE))
|
||||
# M5: the typed-event protocol replaces the delta rule for every campaign,
|
||||
# with or without an inherited stat schema. State is no longer an opt-in
|
||||
# RPG layer — a story has entities, places and possessions whatever genre it
|
||||
# is, so the rule is unconditional.
|
||||
system_sections.append(Section("state_rule", narrative.extract.EMIT_RULE))
|
||||
canon_text = _canon_section(adventure)
|
||||
if canon_text:
|
||||
system_sections.append(Section("campaign_canon", canon_text))
|
||||
|
||||
if isinstance(script_mem.get("context"), str) and script_mem["context"].strip():
|
||||
system_sections.append(Section("script_context", script_mem["context"].strip()))
|
||||
@@ -301,21 +328,20 @@ def build_context(
|
||||
memories_section = Section("used_memories", f"Memories:\n{lines_text}")
|
||||
world_state_section = None
|
||||
refusal_note = ""
|
||||
if has_ws:
|
||||
# One read serves both the in-scene NPCs and the refusal note below.
|
||||
recent = history.tail(adventure, NPC_WINDOW, exclude_action_id)
|
||||
block = worldstate.render_state_section(
|
||||
adventure.world_state, stat_schema, _visible_npcs(recent, stat_schema),
|
||||
persona_name,
|
||||
)
|
||||
if block:
|
||||
world_state_section = Section("world_state", block)
|
||||
# Corrections for the previous AI turn only. A refusal the model has
|
||||
# already had one chance to fix is stale, and repeating it every turn
|
||||
# would price a correction into the whole rest of the adventure.
|
||||
last_ai = next((a for a in reversed(recent) if a.type == "ai"), None)
|
||||
if last_ai is not None:
|
||||
refusal_note = worldstate.render_refusals(last_ai.world_delta)
|
||||
# M5: the authoritative narrative state, as the model is shown it. Read from
|
||||
# the campaign's live document, which head movement keeps pointed at the
|
||||
# position being read — so an undone story is described by the state it had
|
||||
# then, not by the state it reached later.
|
||||
state_block = narrative.render.for_prompt(adventure.narrative_state)
|
||||
if state_block:
|
||||
world_state_section = Section("narrative_state", state_block)
|
||||
# Corrections for the previous AI turn only. A refusal the model has
|
||||
# already had one chance to fix is stale, and repeating it every turn
|
||||
# would price a correction into the whole rest of the adventure.
|
||||
recent = history.tail(adventure, NPC_WINDOW, exclude_action_id)
|
||||
last_ai = next((a for a in reversed(recent) if a.type == "ai"), None)
|
||||
if last_ai is not None:
|
||||
refusal_note = narrative.extract.render_rejections(last_ai.state_rejections)
|
||||
|
||||
authors_note_text = adventure.authors_note.strip()
|
||||
if isinstance(script_mem.get("authorsNote"), str) and script_mem["authorsNote"].strip():
|
||||
@@ -326,7 +352,7 @@ def build_context(
|
||||
if isinstance(script_mem.get("frontMemory"), str):
|
||||
front_memory = script_mem["frontMemory"].strip()
|
||||
|
||||
length_note = length_hint(settings.max_output_tokens, has_ws=has_ws)
|
||||
length_note = length_hint(settings.max_output_tokens)
|
||||
|
||||
# The live sections sit below the history, but they are still part of the
|
||||
# prompt, so they still count against the budget. `world_lore` is the
|
||||
@@ -341,7 +367,7 @@ def build_context(
|
||||
+ count_tokens(authors_note)
|
||||
+ count_tokens(front_memory)
|
||||
+ count_tokens(length_note)
|
||||
+ (count_tokens(worldstate.EMIT_REMINDER) if has_ws else 0)
|
||||
+ count_tokens(narrative.extract.EMIT_REMINDER)
|
||||
+ count_tokens(refusal_note)
|
||||
)
|
||||
available = max(256, settings.context_token_budget - reserved)
|
||||
@@ -386,7 +412,7 @@ def build_context(
|
||||
for action in reversed(actions):
|
||||
# Budget against the text as it appears in the prompt, which includes
|
||||
# the state block when this adventure tracks world state.
|
||||
rendered = _history_text(action) if has_ws else action.text
|
||||
rendered = _history_text(action)
|
||||
tokens = count_tokens(rendered) + count_tokens(SEPARATOR)
|
||||
if spent + tokens > history_budget:
|
||||
if not included_actions:
|
||||
@@ -407,7 +433,7 @@ def build_context(
|
||||
# ----- Assemble the story text, with the author's note near the end -----
|
||||
# Append each AI turn's state block again. The app strips it before storage,
|
||||
# and the recent history has to show the model the pattern to follow.
|
||||
texts = [_history_text(a) if has_ws else a.text for a in included_actions]
|
||||
texts = [_history_text(a) for a in included_actions]
|
||||
note_sections: list[Section] = []
|
||||
if authors_note:
|
||||
pos = max(0, len(texts) - AUTHORS_NOTE_DEPTH)
|
||||
@@ -431,14 +457,13 @@ def build_context(
|
||||
# applies to the block that follows it, so this is also the order in which
|
||||
# the model acts.
|
||||
note_sections.append(Section("length_hint", length_note))
|
||||
if has_ws:
|
||||
# A correction for the previous turn sits directly above the reminder
|
||||
# to emit a block, which is the instruction it modifies.
|
||||
if refusal_note:
|
||||
note_sections.append(Section("world_state_refusals", refusal_note))
|
||||
# The emit rule sits in the system block, far from where the model
|
||||
# generates text, so repeat it last where it has the most effect.
|
||||
note_sections.append(Section("world_state_reminder", worldstate.EMIT_REMINDER))
|
||||
# A correction for the previous turn sits directly above the reminder to
|
||||
# emit a block, which is the instruction it modifies.
|
||||
if refusal_note:
|
||||
note_sections.append(Section("state_refusals", refusal_note))
|
||||
# The emit rule sits in the system block, far from where the model
|
||||
# generates text, so repeat it last where it has the most effect.
|
||||
note_sections.append(Section("state_reminder", narrative.extract.EMIT_REMINDER))
|
||||
|
||||
story_sections = [s for s in note_sections if s.text]
|
||||
system_text = SEPARATOR.join(s.text for s in system_sections if s.text)
|
||||
|
||||
@@ -314,6 +314,40 @@ def move_to(db: Session, adventure: models.Adventure, depth: int) -> None:
|
||||
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:
|
||||
"""Gives the story a new branch when a write would displace a retained future.
|
||||
|
||||
|
||||
@@ -350,6 +350,58 @@ MIGRATIONS: list[tuple[int, str | dict[str, str]]] = [
|
||||
# No backfill.
|
||||
(78, "ALTER TABLE branches ADD COLUMN superseded_at TIMESTAMP"),
|
||||
(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)"),
|
||||
# M5: genre-neutral authoritative narrative state. `create_all` builds the
|
||||
# two new tables — `state_proposals` and `state_events` — as it did
|
||||
# `memories`, `branches` and `checkpoints`; these are the columns it cannot
|
||||
# add to tables that already exist, plus the indexes the audit reads need.
|
||||
#
|
||||
# **No backfill, deliberately.** The inherited RPG world state is numbers
|
||||
# against a stat schema: `player.gold = 70`, `npc.gwen.trust = 3`. Nothing
|
||||
# in that says who Gwen is, where anyone stands, or what anyone holds, and a
|
||||
# narrative fact invented from a number would be fiction the campaign never
|
||||
# established — exactly what the M5 brief forbids. So the old columns are
|
||||
# left intact and non-authoritative, and every campaign starts M5 with an
|
||||
# empty narrative state that its next turns fill in.
|
||||
#
|
||||
# The campaign's own `narrative_state` is left NULL: an adventure with no
|
||||
# M5 turns yet has no document, and the first one writes it.
|
||||
#
|
||||
# Per-action snapshots are a different question, and the M5 corrective pass
|
||||
# settled it the other way (review Finding 3). This block originally left
|
||||
# those NULL too, reasoning that an empty document would be "a claim, not an
|
||||
# absence". The consequence was worse than the claim: restoring to an old
|
||||
# position left the state of a *later* position standing, so the transcript
|
||||
# and the state described different moments. Backfilling the empty document
|
||||
# at version 88 says the only true thing about a pre-M5 position — the
|
||||
# narrative-state system established nothing there, because it did not yet
|
||||
# exist — and keeps head, transcript and state in agreement. The legacy RPG
|
||||
# columns are untouched and still restored beside it.
|
||||
(81, "ALTER TABLE adventures ADD COLUMN narrative_state BLOB"),
|
||||
(82, "ALTER TABLE adventures ADD COLUMN campaign_canon JSON"),
|
||||
(83, "ALTER TABLE actions ADD COLUMN narrative_state_after BLOB"),
|
||||
(84, "ALTER TABLE actions ADD COLUMN state_changes JSON"),
|
||||
(85, "CREATE INDEX IF NOT EXISTS ix_state_events_adventure "
|
||||
"ON state_events (adventure_id, id)"),
|
||||
(86, "CREATE INDEX IF NOT EXISTS ix_state_events_action "
|
||||
"ON state_events (action_id)"),
|
||||
(87, "CREATE INDEX IF NOT EXISTS ix_state_proposals_adventure "
|
||||
"ON state_proposals (adventure_id, id)"),
|
||||
# M5 corrective pass. No DDL — 83 already added the column. This version
|
||||
# exists to carry the data pass that fills it in for rows that predate it,
|
||||
# so that every position an existing campaign can be restored to has a
|
||||
# snapshot. See `_backfill_narrative_snapshots`.
|
||||
(88, "-- narrative snapshot backfill (data pass only)"),
|
||||
]
|
||||
|
||||
LATEST_VERSION = max((v for v, _ in MIGRATIONS), default=1)
|
||||
@@ -363,6 +415,7 @@ TREE_BACKFILL_VERSION = 52
|
||||
CURSOR_ANCHOR_VERSION = 56
|
||||
SIBLING_SPLIT_VERSION = 60
|
||||
PARENT_BACKFILL_VERSION = 64
|
||||
NARRATIVE_SNAPSHOT_VERSION = 88
|
||||
|
||||
# An adventure with no actions has no tip. A value of -1 keeps the rule that the
|
||||
# next node goes at `head_depth + 1` true without a special case. This matches
|
||||
@@ -380,6 +433,30 @@ SNAPSHOT_BATCH = 50
|
||||
BACKFILL_BATCH = 200
|
||||
|
||||
|
||||
def _backfill_narrative_snapshots(conn) -> None:
|
||||
"""Gives every pre-M5 action the empty narrative document as its outcome.
|
||||
|
||||
One statement, no row loop: the document is identical for every row, so it
|
||||
is encoded once in Python and bound as a single parameter. `narrative.model`
|
||||
owns the shape and `compression.pack` owns the encoding, so this cannot
|
||||
drift from what `snapshot_outcome` writes.
|
||||
|
||||
Why the empty document rather than NULL is argued at migration 81. In short:
|
||||
a position with no snapshot used to mean "leave the live state alone", which
|
||||
let a later position's state stand while the reader was somewhere else.
|
||||
"""
|
||||
from .compression import pack
|
||||
from .narrative import model as narrative_model
|
||||
|
||||
conn.execute(
|
||||
text(
|
||||
"UPDATE actions SET narrative_state_after = :document "
|
||||
"WHERE narrative_state_after IS NULL"
|
||||
),
|
||||
{"document": pack(narrative_model.empty())},
|
||||
)
|
||||
|
||||
|
||||
def _backfill_world_delta(conn) -> None:
|
||||
"""Populates `actions.world_delta` from the existing `context_snapshot`.
|
||||
|
||||
@@ -1084,9 +1161,12 @@ def bootstrap(engine: Engine, through: int = LATEST_VERSION) -> None:
|
||||
if current < version <= through:
|
||||
statement = _for_dialect(sql, conn.dialect.name)
|
||||
# Skip the DDL when it has already run. The data pass below it
|
||||
# still runs.
|
||||
if not (_column_already_there(conn, statement)
|
||||
or _column_already_gone(conn, statement)):
|
||||
# still runs. A version whose whole content is a data pass
|
||||
# carries a comment in place of DDL and executes nothing.
|
||||
if not statement.lstrip().startswith("--") and not (
|
||||
_column_already_there(conn, statement)
|
||||
or _column_already_gone(conn, statement)
|
||||
):
|
||||
conn.execute(text(statement))
|
||||
if version == WORLD_DELTA_VERSION:
|
||||
_backfill_world_delta(conn)
|
||||
@@ -1116,5 +1196,7 @@ def bootstrap(engine: Engine, through: int = LATEST_VERSION) -> None:
|
||||
# exist.
|
||||
if version == PARENT_BACKFILL_VERSION:
|
||||
_backfill_parents(conn)
|
||||
if version == NARRATIVE_SNAPSHOT_VERSION:
|
||||
_backfill_narrative_snapshots(conn)
|
||||
current = version
|
||||
_set_version(conn, current)
|
||||
|
||||
@@ -119,7 +119,28 @@ class Adventure(Base):
|
||||
script_state: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
# Phase 12: live RPG world state (world/player/npc stats + milestones),
|
||||
# instantiated from the scenario's stat_schema. Empty when there's no RPG layer.
|
||||
#
|
||||
# **Legacy as of M5**, and no longer authoritative. M5 replaced the
|
||||
# relative-delta protocol this column served (ADR 010); the turn engine no
|
||||
# longer writes it, and nothing reads it to decide anything. It stays so
|
||||
# that a pre-M5 database opens unchanged and its numbers remain visible to
|
||||
# whoever wants to look — `narrative_state` below is what the story means
|
||||
# now. Reinterpreting these values as generic narrative facts would be
|
||||
# inventing meaning the data does not carry, which the M5 brief forbids.
|
||||
world_state: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
# M5: the authoritative narrative state, as it stands at the active head.
|
||||
# Genre-neutral (ADR 006), written only by validated typed events (ADR 010),
|
||||
# and restored from the destination node's snapshot whenever the head moves,
|
||||
# so it always describes the story being read rather than a story the reader
|
||||
# has stepped back from.
|
||||
narrative_state: Mapped[dict] = mapped_column(
|
||||
CompressedJSON, nullable=True, default=None
|
||||
)
|
||||
# Campaign canon: rules the story may not contradict, as configuration
|
||||
# rather than code (C01, J03). A fantasy campaign forbidding resurrection
|
||||
# and a science-fiction one forbidding faster-than-light travel use the same
|
||||
# field and the same validator; neither word appears in the application.
|
||||
campaign_canon: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
# The ${Placeholder} answers collected when this adventure was started, kept
|
||||
# so "Update from scenario" can re-fill freshly copied scenario text with the
|
||||
# same values. NULL for adventures created before this column existed.
|
||||
@@ -241,6 +262,158 @@ class Branch(Base):
|
||||
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 StateProposal(Base):
|
||||
"""M5: what the model proposed, and what the application did about it.
|
||||
|
||||
`DATA-MODEL.md` §19 requires the model's proposal to be *distinct from*
|
||||
accepted state, and this table is that separation made physical. The model
|
||||
writes here; it never writes `state_events`, and it never writes a snapshot.
|
||||
|
||||
A row exists whether the proposal was accepted, partly accepted, rejected or
|
||||
unparseable. A rejected proposal is not authoritative and changes nothing,
|
||||
but it is the record that explains why the state does not say what the
|
||||
narration seems to say — without it, a wrong-looking campaign has no trail
|
||||
to follow. `raw_output` is kept for exactly the case that matters most: the
|
||||
block that did not parse, which no structured column could hold.
|
||||
"""
|
||||
|
||||
__tablename__ = "state_proposals"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
adventure_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("adventures.id", ondelete="CASCADE")
|
||||
)
|
||||
# The node whose narration produced this. NULL only for a manual correction,
|
||||
# which has a coordinate but no narration behind it.
|
||||
action_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("actions.id", ondelete="CASCADE"), nullable=True
|
||||
)
|
||||
branch_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
depth: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
# Which model produced it, so a later comparison of extraction quality has
|
||||
# something to group by. Empty for a manual correction.
|
||||
model_name: Mapped[str] = mapped_column(String(200), default="")
|
||||
# `accepted_story` or `manual_correction` — who is asserting this.
|
||||
source: Mapped[str] = mapped_column(String(40), default="accepted_story")
|
||||
# accepted | partially_accepted | rejected | unparseable
|
||||
status: Mapped[str] = mapped_column(String(30), default="accepted")
|
||||
# The block as written, including when it did not parse.
|
||||
raw_output: Mapped[str] = mapped_column(Text, default="")
|
||||
# The parsed payload, the events accepted, and every rejection with its
|
||||
# reason. Compressed for the same reason the prompt is: a busy turn's
|
||||
# rejections are the largest thing here and nothing reads them in bulk.
|
||||
detail: Mapped[dict | None] = mapped_column(
|
||||
CompressedJSON, nullable=True, deferred=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
|
||||
|
||||
|
||||
class StateEvent(Base):
|
||||
"""M5: one accepted change to the authoritative narrative state.
|
||||
|
||||
The audit half of `DATA-MODEL.md` §17's hybrid. Append-only, ordered, and
|
||||
**never read to reconstruct state** — that is the snapshot's job, and mixing
|
||||
the two would make restore proportional to campaign length, which ADR 012
|
||||
and M4 both forbid.
|
||||
|
||||
What this table answers is §8's list: what changed, why, which turn caused
|
||||
it, whether a model or the user asserted it, and what the value was before.
|
||||
`before` is stored per event rather than derived, because deriving it would
|
||||
mean replaying — the thing the hybrid exists to avoid.
|
||||
|
||||
Events carry the story coordinate as well as the action id. The coordinate
|
||||
survives a retry replacing the live take at that position, exactly as a Save
|
||||
Point's does; the action id says which attempt actually proposed it.
|
||||
"""
|
||||
|
||||
__tablename__ = "state_events"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
adventure_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("adventures.id", ondelete="CASCADE")
|
||||
)
|
||||
proposal_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("state_proposals.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
action_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("actions.id", ondelete="CASCADE"), nullable=True
|
||||
)
|
||||
branch_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
depth: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
# Order within one proposal, so a turn's events replay for a reader in the
|
||||
# order they were applied.
|
||||
sequence: Mapped[int] = mapped_column(Integer, default=0)
|
||||
event_type: Mapped[str] = mapped_column(String(60), default="")
|
||||
payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
# What the affected value was immediately before this event, so the audit
|
||||
# can answer "what did it used to be" without reconstruction. NULL when the
|
||||
# event established something that did not exist.
|
||||
before: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
source: Mapped[str] = mapped_column(String(40), default="accepted_story")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
|
||||
|
||||
|
||||
class Memory(Base):
|
||||
"""Phase 6: an auto-summarized (or hand-written) fact about the adventure.
|
||||
|
||||
@@ -443,10 +616,75 @@ class Action(Base):
|
||||
world_state_after: Mapped[dict | None] = mapped_column(
|
||||
JSON, nullable=True, deferred=True
|
||||
)
|
||||
# M5: the authoritative narrative state as it stood after this node played.
|
||||
# The genre-neutral successor to `world_state_after`, and the reason Undo,
|
||||
# Redo and Save Point restore stay bounded: a position's state is one row
|
||||
# read, not a replay of every event since the campaign began
|
||||
# (`TECHNICAL-DESIGN.md` §10.4, and the M4 note that made it load-bearing
|
||||
# for Save Points too).
|
||||
#
|
||||
# `DATA-MODEL.md` §17 selects the hybrid — validated events for audit, a
|
||||
# snapshot for reads and restore. `state_events` is the audit half; this
|
||||
# column is the restore half, and nothing reconstructs a document from
|
||||
# events.
|
||||
#
|
||||
# Deferred and compressed for the reasons `context_snapshot` is: only the
|
||||
# single node being moved to reads it, and a document carrying a campaign's
|
||||
# entities and facts is larger than the RPG dict it replaces. `world_delta`
|
||||
# has an M5 counterpart in `state_changes` for the bulk read.
|
||||
narrative_state_after: Mapped[dict | None] = mapped_column(
|
||||
CompressedJSON, nullable=True, deferred=True
|
||||
)
|
||||
# The small slice needed in bulk: the events accepted here, the ones
|
||||
# refused, and short lines for the chip under an AI message. Same role
|
||||
# `world_delta` played, and a separate column for the same reason — the
|
||||
# context builder reads it for every action in the replayed history, and
|
||||
# the snapshot beside it is deferred so a turn never loads the prompt
|
||||
# archive. Shape: {"accepted": [...], "rejected": [...], "summary": [...]}.
|
||||
state_changes: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
|
||||
|
||||
adventure: Mapped[Adventure] = relationship(back_populates="actions")
|
||||
|
||||
@property
|
||||
def state_events_replay(self) -> list[dict]:
|
||||
"""M5: the accepted events this turn produced, for replay into the prompt.
|
||||
|
||||
Read from `state_changes`' companion slice in the bulk-read column
|
||||
rather than from the deferred snapshot, because the context builder
|
||||
calls this for every action in the replayed history and loading the
|
||||
prompt archive per action is the egress mistake this project keeps a
|
||||
regression test about.
|
||||
"""
|
||||
changes = self.state_changes
|
||||
if not isinstance(changes, dict):
|
||||
return []
|
||||
events = changes.get("accepted")
|
||||
return events if isinstance(events, list) else []
|
||||
|
||||
@property
|
||||
def state_rejections(self) -> list[dict]:
|
||||
"""M5: what this turn proposed that the application refused.
|
||||
|
||||
Fed back to the model as a correction for one turn only. A refusal it
|
||||
has already had a chance to fix is stale, and repeating it forever would
|
||||
price one bad turn into the rest of the campaign.
|
||||
"""
|
||||
changes = self.state_changes
|
||||
if not isinstance(changes, dict):
|
||||
return []
|
||||
rejected = changes.get("rejected")
|
||||
return rejected if isinstance(rejected, list) else []
|
||||
|
||||
@property
|
||||
def state_summary(self) -> list[str]:
|
||||
"""M5: the short lines shown under an AI message: what changed here."""
|
||||
changes = self.state_changes
|
||||
if not isinstance(changes, dict):
|
||||
return []
|
||||
lines = changes.get("summary")
|
||||
return [str(line) for line in lines] if isinstance(lines, list) else []
|
||||
|
||||
@property
|
||||
def world_changes(self) -> list[dict]:
|
||||
"""Compact per-turn RPG state changes (Phase 12), for the inline summary
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""M5: the authoritative narrative state.
|
||||
|
||||
Genre-neutral state (ADR 006), written by explicit typed events with absolute
|
||||
values (ADR 010), owned by the application rather than the model (ADR 003), and
|
||||
recovered per story position rather than replayed (ADR 012 and
|
||||
`TECHNICAL-DESIGN.md` §10.4).
|
||||
|
||||
extract.split(reply) prose out, proposal out, block kept for audit
|
||||
|
|
||||
validate.review(...) allowlist, schema, references, semantics
|
||||
|
|
||||
apply.apply_events(...) accepted events -> a new state document
|
||||
|
|
||||
store.commit_proposal(...) events, provenance and snapshot, in one transaction
|
||||
|
||||
`model.py` says what a state document is. `render.py` shows it to the model and
|
||||
to the reader. Nothing outside this package writes authoritative state, and
|
||||
nothing inside it executes anything a proposal names.
|
||||
"""
|
||||
|
||||
from . import apply, events, extract, model, render, store, validate # noqa: F401
|
||||
|
||||
__all__ = ["apply", "events", "extract", "model", "render", "store", "validate"]
|
||||
@@ -0,0 +1,272 @@
|
||||
"""M5: turning accepted events into a new state document.
|
||||
|
||||
Pure and total. Every function here takes a document and returns a new one; none
|
||||
touches the database, and none can fail on an event `validate.review` accepted —
|
||||
validation is the only place an event is refused, so this module never has to
|
||||
decide anything twice.
|
||||
|
||||
The dispatch is an explicit `if/elif` chain over `events.SPECS`, not a lookup
|
||||
table keyed on the payload. The difference matters: a table maps a string a model
|
||||
supplied to a callable, and the security of that arrangement rests entirely on
|
||||
the allowlist being correct. A chain of literal comparisons cannot be steered by
|
||||
a payload at all, whatever the allowlist does.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
|
||||
from . import model
|
||||
|
||||
|
||||
def apply_events(
|
||||
state: dict,
|
||||
accepted: list[dict],
|
||||
*,
|
||||
branch_id: int | None = None,
|
||||
depth: int | None = None,
|
||||
source: str = "accepted_story",
|
||||
) -> dict:
|
||||
"""Returns `state` with every event in `accepted` applied, in order.
|
||||
|
||||
The input document is never mutated: head movement stores snapshots by
|
||||
reference in places, and a mutation here would edit the past.
|
||||
|
||||
`branch_id`/`depth` stamp facts and relationships with where they were
|
||||
established, which is what makes the audit trail answer "which turn caused
|
||||
this" without a join. `source` records whether the campaign, the story or
|
||||
the user established it — C04's provenance, carried on the value itself.
|
||||
"""
|
||||
document = model.normalize(state)
|
||||
for event in accepted:
|
||||
_apply_one(document, event, branch_id, depth, source)
|
||||
return document
|
||||
|
||||
|
||||
def _apply_one(state: dict, event: dict, branch_id, depth, source: str) -> None:
|
||||
kind = event["type"]
|
||||
|
||||
if kind == "create_entity":
|
||||
state["entities"][event["entity"]] = model.new_entity(
|
||||
type=event.get("entity_type") or "other",
|
||||
name=event["name"],
|
||||
description=event.get("description") or "",
|
||||
aliases=event.get("aliases") or [],
|
||||
)
|
||||
|
||||
elif kind == "set_entity_status":
|
||||
_entity(state, event["entity"])["status"] = event["status"]
|
||||
|
||||
elif kind == "set_entity_attribute":
|
||||
# Absolute assignment. The whole reason ADR 010 exists.
|
||||
_entity(state, event["entity"])["attributes"][event["attribute"]] = event["value"]
|
||||
|
||||
elif kind == "set_entity_conditions":
|
||||
_entity(state, event["entity"])["conditions"] = list(event["conditions"])
|
||||
|
||||
elif kind == "set_current_location":
|
||||
_entity(state, event["entity"])["location"] = event["location"]
|
||||
|
||||
elif kind == "set_possession":
|
||||
state["possessions"][event["item"]] = event["owner"]
|
||||
|
||||
elif kind == "clear_possession":
|
||||
state["possessions"].pop(event["item"], None)
|
||||
|
||||
elif kind == "add_fact":
|
||||
state["facts"].append({
|
||||
"id": event.get("fact_id") or _fact_id(state),
|
||||
"subject": event.get("subject"),
|
||||
"predicate": event["predicate"],
|
||||
"object": event.get("object"),
|
||||
"value": event.get("value"),
|
||||
"authority": _authority(source),
|
||||
"source": source,
|
||||
"status": "active",
|
||||
"branch_id": branch_id,
|
||||
"depth": depth,
|
||||
})
|
||||
|
||||
elif kind == "invalidate_fact":
|
||||
for fact in state["facts"]:
|
||||
if fact.get("id") == event["fact_id"]:
|
||||
# Withdrawn, not removed: C04 needs the record of what the
|
||||
# campaign used to believe, and a deleted row audits nothing.
|
||||
fact["status"] = "invalidated"
|
||||
fact["invalidated_by"] = source
|
||||
fact["invalidated_at"] = {"branch_id": branch_id, "depth": depth}
|
||||
if event.get("reason"):
|
||||
fact["invalidated_reason"] = event["reason"]
|
||||
|
||||
elif kind == "add_relationship":
|
||||
state["relationships"].append({
|
||||
"id": _relationship_id(state),
|
||||
"source": event["source"],
|
||||
"target": event["target"],
|
||||
"type": event["relationship"],
|
||||
"description": event.get("description") or "",
|
||||
"status": "active",
|
||||
"established_by": source,
|
||||
"branch_id": branch_id,
|
||||
"depth": depth,
|
||||
})
|
||||
|
||||
elif kind == "end_relationship":
|
||||
for relationship in state["relationships"]:
|
||||
if (
|
||||
relationship.get("source") == event["source"]
|
||||
and relationship.get("target") == event["target"]
|
||||
and relationship.get("type") == event["relationship"]
|
||||
and relationship.get("status") == "active"
|
||||
):
|
||||
relationship["status"] = "ended"
|
||||
relationship["ended_at"] = {"branch_id": branch_id, "depth": depth}
|
||||
|
||||
elif kind == "open_story_thread":
|
||||
state["threads"][event["thread"]] = {
|
||||
"title": event["title"],
|
||||
"description": event.get("description") or "",
|
||||
"status": "open",
|
||||
"opened_at": {"branch_id": branch_id, "depth": depth},
|
||||
}
|
||||
|
||||
elif kind == "resolve_story_thread":
|
||||
thread = state["threads"].get(event["thread"])
|
||||
if isinstance(thread, dict):
|
||||
thread["status"] = "resolved"
|
||||
thread["resolution"] = event.get("resolution") or ""
|
||||
thread["resolved_at"] = {"branch_id": branch_id, "depth": depth}
|
||||
|
||||
elif kind == "set_scene":
|
||||
scene = dict(state.get("scene") or {})
|
||||
if "summary" in event:
|
||||
scene["summary"] = event["summary"]
|
||||
if "location" in event:
|
||||
scene["location"] = event["location"]
|
||||
if "present" in event:
|
||||
scene["present"] = list(event["present"] or [])
|
||||
scene["at"] = {"branch_id": branch_id, "depth": depth}
|
||||
state["scene"] = scene
|
||||
|
||||
# No `else`. Every allowed type is handled above, and an unhandled one
|
||||
# cannot arrive: `validate.review` refuses anything outside the allowlist,
|
||||
# and the allowlist is this list. A silent fall-through would be the one way
|
||||
# an event could appear accepted and do nothing.
|
||||
|
||||
|
||||
def _entity(state: dict, key: str) -> dict:
|
||||
"""The entity record for `key`, created bare if a snapshot lost it.
|
||||
|
||||
Validation guarantees the entity exists, so this is a repair path for a
|
||||
hand-edited or partially imported document rather than a normal branch. A
|
||||
bare record is better than a KeyError: the story is still readable, and the
|
||||
inspector shows an entity with nothing known about it, which is true.
|
||||
"""
|
||||
entities = state["entities"]
|
||||
found = entities.get(key)
|
||||
if not isinstance(found, dict):
|
||||
found = model.new_entity(name=key)
|
||||
entities[key] = found
|
||||
found.setdefault("attributes", {})
|
||||
found.setdefault("conditions", [])
|
||||
return found
|
||||
|
||||
|
||||
def _authority(source: str) -> str:
|
||||
"""Which authority band a source's assertions carry.
|
||||
|
||||
A user's correction outranks the story (C04); the story outranks a guess.
|
||||
`DATA-MODEL.md` §14 orders the bands, and this is the mapping into them.
|
||||
"""
|
||||
if source == "manual_correction":
|
||||
return "manual_correction"
|
||||
if source == "campaign_canon":
|
||||
return "campaign_canon"
|
||||
return "accepted_story"
|
||||
|
||||
|
||||
def _fact_id(state: dict) -> str:
|
||||
return f"f{len(state['facts']) + 1}"
|
||||
|
||||
|
||||
def _relationship_id(state: dict) -> str:
|
||||
return f"r{len(state['relationships']) + 1}"
|
||||
|
||||
|
||||
def diff(before: dict, after: dict) -> list[str]:
|
||||
"""A short human-readable list of what changed between two documents.
|
||||
|
||||
Shown under a turn the way the world-state chip used to be, and recorded on
|
||||
the node for the bulk read. Text rather than structure, because its only
|
||||
consumer is a person reading "Aldric now holds the silver key".
|
||||
"""
|
||||
before = model.normalize(before)
|
||||
after = model.normalize(after)
|
||||
lines: list[str] = []
|
||||
|
||||
for key, entity in after["entities"].items():
|
||||
was = before["entities"].get(key)
|
||||
name = model.entity_name(after, key)
|
||||
if was is None:
|
||||
lines.append(f"{name} enters the story")
|
||||
continue
|
||||
if was.get("status") != entity.get("status"):
|
||||
lines.append(f"{name} is now {entity.get('status')}")
|
||||
if was.get("location") != entity.get("location") and entity.get("location"):
|
||||
lines.append(f"{name} is at {model.entity_name(after, entity['location'])}")
|
||||
if sorted(was.get("conditions") or []) != sorted(entity.get("conditions") or []):
|
||||
now = ", ".join(entity.get("conditions") or []) or "nothing"
|
||||
lines.append(f"{name}: {now}")
|
||||
for attribute, value in (entity.get("attributes") or {}).items():
|
||||
if (was.get("attributes") or {}).get(attribute) != value:
|
||||
lines.append(f"{name} {attribute} = {value}")
|
||||
|
||||
for item, owner in after["possessions"].items():
|
||||
if before["possessions"].get(item) != owner:
|
||||
lines.append(
|
||||
f"{model.entity_name(after, item)} → {model.entity_name(after, owner)}"
|
||||
)
|
||||
for item in before["possessions"]:
|
||||
if item not in after["possessions"]:
|
||||
lines.append(f"{model.entity_name(after, item)} is held by nobody")
|
||||
|
||||
known = {f.get("id") for f in before["facts"]}
|
||||
for fact in after["facts"]:
|
||||
if fact.get("id") not in known:
|
||||
lines.append(f"fact: {_fact_text(after, fact)}")
|
||||
was_active = {f["id"] for f in model.active_facts(before)}
|
||||
for fact in before["facts"]:
|
||||
if fact.get("id") in was_active and fact.get("id") not in {
|
||||
f["id"] for f in model.active_facts(after)
|
||||
}:
|
||||
lines.append(f"withdrawn: {_fact_text(after, fact)}")
|
||||
|
||||
known = {r.get("id") for r in before["relationships"]}
|
||||
for relationship in after["relationships"]:
|
||||
if relationship.get("id") not in known:
|
||||
lines.append(
|
||||
f"{model.entity_name(after, relationship['source'])} "
|
||||
f"{relationship['type']} "
|
||||
f"{model.entity_name(after, relationship['target'])}"
|
||||
)
|
||||
|
||||
for key, thread in after["threads"].items():
|
||||
was = before["threads"].get(key)
|
||||
if was is None:
|
||||
lines.append(f"opened: {thread.get('title', key)}")
|
||||
elif was.get("status") != thread.get("status"):
|
||||
lines.append(f"{thread.get('status')}: {thread.get('title', key)}")
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def _fact_text(state: dict, fact: dict) -> str:
|
||||
parts = []
|
||||
if fact.get("subject"):
|
||||
parts.append(model.entity_name(state, fact["subject"]))
|
||||
parts.append(str(fact.get("predicate", "")))
|
||||
if fact.get("object"):
|
||||
parts.append(model.entity_name(state, fact["object"]))
|
||||
if fact.get("value") is not None:
|
||||
parts.append(str(fact["value"]))
|
||||
return " ".join(p for p in parts if p)
|
||||
@@ -0,0 +1,194 @@
|
||||
"""M5: the typed event vocabulary, and the allowlist that bounds it.
|
||||
|
||||
ADR 010 replaced AI-DnD's relative-delta protocol because the ambiguity was
|
||||
architectural: a number in a delta field is syntactically legal whether the
|
||||
model meant "add 50" or "set to 50", and no validator can tell which. Every
|
||||
event here therefore states its operation in its `type`, and every value it
|
||||
carries is **absolute**. There is no event whose meaning depends on a prompt
|
||||
instruction having been followed.
|
||||
|
||||
## The allowlist is a security boundary, not a convenience
|
||||
|
||||
Model output is untrusted input (`SECURITY-THREAT-MODEL.md`), and this table is
|
||||
the entire set of things a model may cause to happen. H05's
|
||||
`{"event_type": "execute_shell", ...}` is refused here — not because "shell" is
|
||||
recognised and blocked, but because it is not in `SPECS`, and nothing outside
|
||||
`SPECS` is dispatched. There is no fallback branch, no generic handler and no
|
||||
name-to-callable lookup that a payload could steer.
|
||||
|
||||
Adding an event means adding a spec here and a case in `apply.py`. Nothing else
|
||||
in the application can widen the vocabulary, which is what keeps
|
||||
"state extraction" from drifting into "tool execution".
|
||||
|
||||
## Shape of a spec
|
||||
|
||||
required fields that must be present and non-empty
|
||||
optional fields that may be present
|
||||
refs fields naming an entity that must already exist
|
||||
creates the field naming an entity this event may bring into being
|
||||
|
||||
`refs` is what `validate.py` uses for referential integrity, and `creates` is
|
||||
the deliberate exception: exactly one event type may introduce an entity, so a
|
||||
typo in any other event surfaces as an unknown reference rather than silently
|
||||
creating a second, empty Mara.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Field types the schema layer enforces. Kept deliberately small: a narrative
|
||||
# state event carries names, labels and plain values, and nothing here needs a
|
||||
# nested structure a model could hide something inside.
|
||||
TEXT = "text"
|
||||
KEY = "key" # an entity/thread identifier: a slug the campaign chose
|
||||
VALUE = "value" # a JSON scalar — str, int, float, bool or None
|
||||
LABELS = "labels" # a list of short strings
|
||||
|
||||
#: The whole vocabulary. Nothing outside this mapping is dispatched, ever.
|
||||
SPECS: dict[str, dict] = {
|
||||
"create_entity": {
|
||||
"required": {"entity": KEY, "name": TEXT},
|
||||
"optional": {"entity_type": TEXT, "description": TEXT, "aliases": LABELS},
|
||||
"refs": (),
|
||||
"creates": "entity",
|
||||
"summary": "brings a person, place, thing or group into the story",
|
||||
},
|
||||
"set_entity_status": {
|
||||
"required": {"entity": KEY, "status": TEXT},
|
||||
"optional": {},
|
||||
"refs": ("entity",),
|
||||
"creates": None,
|
||||
"summary": "sets whether an entity is active, gone, destroyed …",
|
||||
},
|
||||
"set_entity_attribute": {
|
||||
# The one numeric-capable event, and it is an assignment. ADR 010's
|
||||
# `set_value`: the operation is in the name, so a value of 50 can only
|
||||
# mean fifty. An `increment_value` could be added later without
|
||||
# ambiguity, because it would be a different `type`.
|
||||
"required": {"entity": KEY, "attribute": TEXT, "value": VALUE},
|
||||
"optional": {},
|
||||
"refs": ("entity",),
|
||||
"creates": None,
|
||||
"summary": "sets a named value on an entity, absolutely",
|
||||
},
|
||||
"set_entity_conditions": {
|
||||
# Absolute too: the full set replaces the old one. "Add a condition"
|
||||
# would need the current set to be known by the model, which is exactly
|
||||
# the assumption that made deltas unreliable.
|
||||
"required": {"entity": KEY, "conditions": LABELS},
|
||||
"optional": {},
|
||||
"refs": ("entity",),
|
||||
"creates": None,
|
||||
"summary": "replaces the conditions an entity is under",
|
||||
},
|
||||
"set_current_location": {
|
||||
"required": {"entity": KEY, "location": KEY},
|
||||
"optional": {},
|
||||
"refs": ("entity", "location"),
|
||||
"creates": None,
|
||||
"summary": "moves an entity to a location",
|
||||
},
|
||||
"set_possession": {
|
||||
"required": {"item": KEY, "owner": KEY},
|
||||
"optional": {},
|
||||
"refs": ("item", "owner"),
|
||||
"creates": None,
|
||||
"summary": "gives an item to an owner",
|
||||
},
|
||||
"clear_possession": {
|
||||
"required": {"item": KEY},
|
||||
"optional": {},
|
||||
"refs": ("item",),
|
||||
"creates": None,
|
||||
"summary": "leaves an item held by nobody",
|
||||
},
|
||||
"add_fact": {
|
||||
"required": {"predicate": TEXT},
|
||||
"optional": {
|
||||
"subject": KEY, "object": KEY, "value": VALUE, "fact_id": TEXT,
|
||||
},
|
||||
# Only the subject is checked as an entity. The *object* of a fact is
|
||||
# routinely not one — "Mara knows where the key was found" has another
|
||||
# fact as its object, and C03 needs exactly that — so it is checked
|
||||
# against entities *and* known facts in `validate._check`. Requiring an
|
||||
# entity here would make the knowledge distinction C03 asks for
|
||||
# unrepresentable.
|
||||
"refs": ("subject",),
|
||||
"creates": None,
|
||||
"summary": "asserts something about the world",
|
||||
},
|
||||
"invalidate_fact": {
|
||||
"required": {"fact_id": TEXT},
|
||||
"optional": {"reason": TEXT},
|
||||
"refs": (),
|
||||
"creates": None,
|
||||
"summary": "withdraws a fact without deleting the record of it",
|
||||
},
|
||||
"add_relationship": {
|
||||
"required": {"source": KEY, "target": KEY, "relationship": TEXT},
|
||||
"optional": {"description": TEXT},
|
||||
"refs": ("source", "target"),
|
||||
"creates": None,
|
||||
"summary": "ties two entities together",
|
||||
},
|
||||
"end_relationship": {
|
||||
"required": {"source": KEY, "target": KEY, "relationship": TEXT},
|
||||
"optional": {},
|
||||
"refs": ("source", "target"),
|
||||
"creates": None,
|
||||
"summary": "ends a tie without erasing that it existed",
|
||||
},
|
||||
"open_story_thread": {
|
||||
"required": {"thread": KEY, "title": TEXT},
|
||||
"optional": {"description": TEXT},
|
||||
"refs": (),
|
||||
"creates": None,
|
||||
"summary": "records narrative business left open",
|
||||
},
|
||||
"resolve_story_thread": {
|
||||
"required": {"thread": KEY},
|
||||
"optional": {"resolution": TEXT},
|
||||
"refs": (),
|
||||
"creates": None,
|
||||
"summary": "closes narrative business",
|
||||
},
|
||||
"set_scene": {
|
||||
"required": {},
|
||||
"optional": {"summary": TEXT, "location": KEY, "present": LABELS},
|
||||
"refs": ("location",),
|
||||
"creates": None,
|
||||
"summary": "records the immediate situation",
|
||||
},
|
||||
}
|
||||
|
||||
#: The allowlist itself, as a set, for the one question that matters most.
|
||||
ALLOWED = frozenset(SPECS)
|
||||
|
||||
|
||||
def is_allowed(event_type) -> bool:
|
||||
"""Whether `event_type` names an event this application will ever apply.
|
||||
|
||||
A string is required: a dict, a list or None is not a type, and coercing one
|
||||
with `str()` would turn a malformed payload into a lookup that might
|
||||
accidentally succeed.
|
||||
"""
|
||||
return isinstance(event_type, str) and event_type in ALLOWED
|
||||
|
||||
|
||||
def spec(event_type: str) -> dict | None:
|
||||
return SPECS.get(event_type)
|
||||
|
||||
|
||||
def vocabulary_for_prompt() -> str:
|
||||
"""The event list as the narrator prompt describes it.
|
||||
|
||||
Generated from `SPECS` rather than written out beside it, so the model can
|
||||
never be told about an event the application does not implement — the drift
|
||||
that would produce proposals rejected for reasons nobody could see.
|
||||
"""
|
||||
lines = []
|
||||
for name, definition in SPECS.items():
|
||||
fields = list(definition["required"]) + [
|
||||
f"{field}?" for field in definition["optional"]
|
||||
]
|
||||
lines.append(f' {name}({", ".join(fields)}) — {definition["summary"]}')
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,259 @@
|
||||
"""M5: getting a typed proposal out of a narration, and keeping it out of the prose.
|
||||
|
||||
The model writes the story and, after it, one fenced block of typed events. This
|
||||
module holds the instruction it is given, the parser that survives the ways a
|
||||
model gets a format wrong, and the separation that keeps machine-readable output
|
||||
from reaching the reader.
|
||||
|
||||
Two properties matter more than elegance here:
|
||||
|
||||
* **The prose must never carry the protocol.** A reader should not see a JSON
|
||||
block under their story, and a stored narration should not contain one either,
|
||||
because everything downstream — memory, summaries, export, the transcript —
|
||||
treats stored text as the story. The block is removed before the text is
|
||||
stored, not before it is displayed.
|
||||
* **An unreadable block must not be a failed turn.** A narration the user watched
|
||||
arrive is worth keeping even when the state block after it is garbage. Parsing
|
||||
returns "no events" rather than raising, the turn commits with the state
|
||||
unchanged, and the proposal record keeps the raw output so the failure is
|
||||
visible in the audit rather than only in a log.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
from . import events
|
||||
|
||||
# The block the model is asked to append. Built from the vocabulary rather than
|
||||
# written beside it, so the instruction cannot describe an event the application
|
||||
# would then reject (`events.vocabulary_for_prompt`).
|
||||
EMIT_RULE = (
|
||||
"After your narration, append a fenced code block labelled `state` containing "
|
||||
"a JSON object with an \"events\" list, recording what your own narration made "
|
||||
"true. Treat your narration as authoritative: if you wrote that someone moved, "
|
||||
"took something, learned something, was hurt, or that a new person or place "
|
||||
"appeared, record it.\n"
|
||||
"\n"
|
||||
"Every value is ABSOLUTE — the new state of things, never a change or a "
|
||||
"difference. Use only these events:\n"
|
||||
f"{events.vocabulary_for_prompt()}\n"
|
||||
"\n"
|
||||
"Identifiers are short lower-case slugs (mara, silver-key, old-abbey) and must "
|
||||
"match the ones already in the state you were shown. Introduce a person, place "
|
||||
"or thing with create_entity before referring to it. If the turn established "
|
||||
"nothing, send an empty events list.\n"
|
||||
"Example:\n"
|
||||
'```state\n'
|
||||
'{"events": [{"type": "set_possession", "item": "silver-key", "owner": "aldric"},'
|
||||
' {"type": "set_current_location", "entity": "aldric", "location": "old-abbey"}]}\n'
|
||||
'```'
|
||||
)
|
||||
|
||||
# Placed last, where recency is strongest, the same way the delta protocol did.
|
||||
EMIT_REMINDER = (
|
||||
"[Reminder: end your reply with a ```state block listing the events your "
|
||||
"narration made true, with absolute values. Send an empty events list if "
|
||||
"nothing changed.]"
|
||||
)
|
||||
|
||||
# Three patterns, and the difference between them is the whole of this module's
|
||||
# safety. A story is allowed to contain code, and taking a code block out of
|
||||
# someone's prose is a worse failure than leaving a stray proposal in it.
|
||||
#
|
||||
# `state` is the label the application asks for, so a fence carrying it is ours
|
||||
# whatever is inside it — including a truncated `{oh no` that no JSON parser
|
||||
# will take. That block must still leave the prose, and must still be recorded,
|
||||
# because an unparseable proposal is exactly the failure the audit exists to
|
||||
# make visible.
|
||||
_STATE_FENCE_RE = re.compile(
|
||||
r"```state[^\S\n]*\n?(.*?)```", re.DOTALL | re.IGNORECASE
|
||||
)
|
||||
# `json` is *not* our label. Models reach for it anyway, so a ```json fence is
|
||||
# taken only when what it contains is actually a proposal. A character who
|
||||
# writes `{"name": "Mara"}` into a terminal keeps their code block (M5 review,
|
||||
# Finding 6).
|
||||
_JSON_FENCE_RE = re.compile(
|
||||
r"```json[^\S\n]*\n?(.*?)```", re.DOTALL | re.IGNORECASE
|
||||
)
|
||||
# An *unlabelled* fence is ours on the same terms: it has to be a proposal, not
|
||||
# merely JSON-shaped.
|
||||
_BARE_FENCE_RE = re.compile(r"```\s*([\[{].*?[\]}])\s*```", re.DOTALL)
|
||||
|
||||
# A bare object hugging the end of the text, for a model that forgets the fence.
|
||||
_TRAILING_RE = re.compile(r"(\{.*\})\s*$", re.DOTALL)
|
||||
|
||||
# An opener with no closing fence. A model that runs out of output tokens
|
||||
# mid-block leaves one of these, and everything after it is protocol rather than
|
||||
# story — so the story ends where the opener begins.
|
||||
#
|
||||
# Our own label ends the story unconditionally. A dangling ```json fence is
|
||||
# judged on what follows it, because an unterminated code block in a story is
|
||||
# still the author's (M5 review, Finding 6).
|
||||
_DANGLING_STATE_RE = re.compile(r"\n?```state\b.*\Z", re.DOTALL | re.IGNORECASE)
|
||||
_DANGLING_JSON_RE = re.compile(r"\n?```json\b(.*)\Z", re.DOTALL | re.IGNORECASE)
|
||||
|
||||
# The reminder, parroted back. Small local models reproduce the bracketed
|
||||
# instruction they were given, and it arrives as ordinary prose — no fence, so
|
||||
# nothing above strips it, and the reader is shown a piece of the prompt.
|
||||
#
|
||||
# The bracket is *found* broadly and *judged* narrowly. Merely naming the
|
||||
# protocol is not enough: a story may end on an aside about a state block, and
|
||||
# deleting that sentence is the worse failure (M5 review, Finding 6). What marks
|
||||
# the echo is the shape of the instruction itself — the fence token, the word it
|
||||
# opens with, or the pair of phrases the reminder uses together.
|
||||
_TRAILING_BRACKET_RE = re.compile(r"\n?\[([^\]]*)\]\s*\Z", re.DOTALL)
|
||||
|
||||
|
||||
def _is_echoed_instruction(inner: str) -> bool:
|
||||
"""Whether a trailing bracketed segment is the prompt's own reminder."""
|
||||
low = inner.lower()
|
||||
if "```state" in low:
|
||||
return True
|
||||
if low.lstrip().startswith("reminder:"):
|
||||
return True
|
||||
# The reminder names both; prose about the protocol rarely names either the
|
||||
# way the instruction does, and effectively never both.
|
||||
return "state block" in low and "events list" in low
|
||||
|
||||
|
||||
def _clean(prose: str) -> str:
|
||||
"""Removes protocol the block extraction could not, and nothing else.
|
||||
|
||||
Found by the M5 realistic-context run (§12), which is the failure class
|
||||
Phase 0B warned about: under a full prompt the model echoed its own
|
||||
instruction into the narration, and the reader would have been shown it.
|
||||
Neither case here is hypothetical — both were observed against a real local
|
||||
model.
|
||||
"""
|
||||
cleaned = prose
|
||||
bracket = _TRAILING_BRACKET_RE.search(cleaned)
|
||||
if bracket is not None and _is_echoed_instruction(bracket.group(1)):
|
||||
cleaned = cleaned[: bracket.start()]
|
||||
cleaned = _DANGLING_STATE_RE.sub("", cleaned)
|
||||
dangling = _DANGLING_JSON_RE.search(cleaned)
|
||||
if dangling is not None and _reads_as_protocol(dangling.group(1)):
|
||||
cleaned = cleaned[: dangling.start()]
|
||||
return cleaned.strip()
|
||||
|
||||
|
||||
def _reads_as_protocol(tail: str) -> bool:
|
||||
"""Whether a truncated fence was on its way to being a proposal."""
|
||||
if '"events"' in tail:
|
||||
return True
|
||||
return any(f'"{name}"' in tail for name in events.SPECS)
|
||||
|
||||
|
||||
def _tolerant_load(blob: str):
|
||||
"""Parses a block, forgiving what small local models get wrong.
|
||||
|
||||
Trailing commas and a leading `+` on a number are both common and both
|
||||
rejected by strict JSON. Repairing them is not guessing at meaning — the
|
||||
intended value is unambiguous — which is the line this function stays on the
|
||||
right side of. Anything it cannot parse returns None, and the caller treats
|
||||
that as no proposal rather than as an empty one.
|
||||
"""
|
||||
cleaned = re.sub(r",(\s*[}\]])", r"\1", blob)
|
||||
cleaned = re.sub(r"(:\s*)\+(\d)", r"\1\2", cleaned)
|
||||
try:
|
||||
parsed = json.loads(cleaned)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return None
|
||||
return parsed
|
||||
|
||||
|
||||
def split(text: str) -> tuple[str, dict | None, str]:
|
||||
"""Separates a reply into `(prose, proposal, raw_block)`.
|
||||
|
||||
`proposal` is None when there is no block or it cannot be parsed at all,
|
||||
which the caller records as a malformed proposal. `raw_block` is what the
|
||||
model actually wrote, kept for the audit record even — especially — when it
|
||||
did not parse.
|
||||
|
||||
A bare trailing object is only stripped when it parses *and* looks like a
|
||||
proposal. Prose that happens to end in a brace is left alone, because
|
||||
removing a sentence from someone's story to satisfy a regex is a worse
|
||||
failure than leaving a stray brace in it.
|
||||
"""
|
||||
matches = list(_STATE_FENCE_RE.finditer(text))
|
||||
if matches:
|
||||
match = matches[-1]
|
||||
raw = match.group(1).strip()
|
||||
prose = _clean(text[: match.start()] + text[match.end():])
|
||||
return prose, _tolerant_load(raw), raw
|
||||
|
||||
# A `json` or unlabelled fence is ours only when its contents are this
|
||||
# protocol. That is judged two ways, and it needs both: a block that parses
|
||||
# into a proposal, or one that plainly reads as protocol even though it does
|
||||
# not parse. The second half matters — a small model that mangles its own
|
||||
# JSON must not have the wreckage shown to the reader, which is what the
|
||||
# realistic-model run caught during the corrective pass.
|
||||
for pattern in (_JSON_FENCE_RE, _BARE_FENCE_RE):
|
||||
for match in reversed(list(pattern.finditer(text))):
|
||||
raw = match.group(1).strip()
|
||||
parsed = _tolerant_load(raw)
|
||||
if _looks_like_proposal(parsed) or _reads_as_protocol(raw):
|
||||
prose = _clean(text[: match.start()] + text[match.end():])
|
||||
return prose, parsed, raw
|
||||
|
||||
match = _TRAILING_RE.search(text)
|
||||
if match:
|
||||
raw = match.group(1)
|
||||
parsed = _tolerant_load(raw)
|
||||
if _looks_like_proposal(parsed):
|
||||
return _clean(text[: match.start()]), parsed, raw
|
||||
|
||||
# No block at all — but the reply may still carry protocol the model wrote
|
||||
# as prose, or a fence it never closed.
|
||||
cleaned = _clean(text)
|
||||
if cleaned != text.strip():
|
||||
return cleaned, None, text.strip()[len(cleaned):].strip()
|
||||
return cleaned, None, ""
|
||||
|
||||
|
||||
def _looks_like_proposal(parsed) -> bool:
|
||||
"""Whether a bare trailing object is this protocol rather than prose."""
|
||||
if not isinstance(parsed, dict):
|
||||
return False
|
||||
if isinstance(parsed.get("events"), list):
|
||||
return True
|
||||
return isinstance(parsed.get("type"), str) and events.is_allowed(parsed["type"])
|
||||
|
||||
|
||||
def render_block(accepted: list[dict]) -> str:
|
||||
"""Renders accepted events back into the block the model emitted.
|
||||
|
||||
Replayed into the prompt for past turns so the model copies the format it is
|
||||
being asked for. **Accepted** events rather than proposed ones, for the
|
||||
reason the delta protocol learned the hard way: showing the model a refused
|
||||
event standing as though it had worked, contradicted by the state in the
|
||||
same prompt, teaches it to send the event again.
|
||||
"""
|
||||
if not accepted:
|
||||
return ""
|
||||
return "```state\n" + json.dumps({"events": accepted}, ensure_ascii=False) + "\n```"
|
||||
|
||||
|
||||
def render_rejections(rejected: list[dict]) -> str:
|
||||
"""The correction note appended after the most recent AI turn.
|
||||
|
||||
Only what was lost. A model that is told what it got wrong can fix it next
|
||||
turn; a model told nothing repeats it.
|
||||
"""
|
||||
if not rejected:
|
||||
return ""
|
||||
lines = []
|
||||
for entry in rejected[:6]:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
detail = entry.get("detail") or entry.get("reason") or ""
|
||||
if detail:
|
||||
lines.append(f"- {detail}")
|
||||
if not lines:
|
||||
return ""
|
||||
body = "\n".join(lines)
|
||||
return (
|
||||
"[Part of your last state block was not accepted. Correct it in this "
|
||||
f"turn's block:\n{body}]"
|
||||
)
|
||||
@@ -0,0 +1,307 @@
|
||||
"""M5: the authoritative narrative state, and what shape it has.
|
||||
|
||||
This is the genre-neutral state ADR 006 requires and ADR 010's typed events
|
||||
write into. It replaces the inherited RPG world state, which assumed stats,
|
||||
bands, cooldowns and per-turn delta caps — assumptions that are a *game system*,
|
||||
not a story.
|
||||
|
||||
## What a state document is
|
||||
|
||||
One JSON document per story position, holding what the campaign currently
|
||||
believes:
|
||||
|
||||
entities the things that exist: who, where, what
|
||||
possessions which entity holds which item
|
||||
facts assertions about the world, with an authority
|
||||
relationships directed ties between entities
|
||||
threads narrative business that is open or resolved
|
||||
scene the immediate situation
|
||||
|
||||
Nothing here names a genre. A character, a location, an organization, an item
|
||||
and a vehicle are all `entities` with a `type`, which is a descriptive label the
|
||||
campaign chooses, not a branch in the code (`DATA-MODEL.md` §9). The same
|
||||
document holds Aldric in an abbey and the Persephone at Ceres Station, and
|
||||
`J03` is satisfied because moving between them is data.
|
||||
|
||||
## Why a document rather than normalised tables
|
||||
|
||||
`DATA-MODEL.md` §17 selects the **hybrid**: validated events for audit, plus a
|
||||
snapshot for reads and restore. M3 and M4 make that choice load-bearing rather
|
||||
than an optimisation. Every position in a retained story must be recoverable in
|
||||
bounded time — `TECHNICAL-DESIGN.md` §10.4 — because Undo, Redo and Save Point
|
||||
restore all resolve a coordinate and read the state recorded there. Current-value
|
||||
tables would leave the *future's* values standing when the head moves back, which
|
||||
`BUILD-MILESTONES.md` M5 forbids in as many words, and rebuilding them would mean
|
||||
replaying the campaign.
|
||||
|
||||
So the authoritative current state is this document, snapshotted per node exactly
|
||||
as the world state was, and the event log beside it is the audit record rather
|
||||
than the reconstruction path. The events say *why* the document changed; the
|
||||
document says what is true now.
|
||||
|
||||
Everything in this module is pure. It builds and reads documents; it does not
|
||||
touch the database, and it does not decide whether a proposal is acceptable —
|
||||
that is `validate.py`, and applying an accepted event is `apply.py`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
|
||||
# The document version, so a later milestone can migrate a stored snapshot
|
||||
# without guessing what it was written by. Bump only for a shape change that a
|
||||
# reader cannot infer.
|
||||
VERSION = 1
|
||||
|
||||
# Entity categories the product suggests. This is a vocabulary, not a
|
||||
# constraint: `DATA-MODEL.md` §9 calls these "descriptive categories, not
|
||||
# separate game systems", so an unknown type is accepted and simply described.
|
||||
# Rejecting one would make the schema genre-specific by the back door.
|
||||
SUGGESTED_TYPES = (
|
||||
"character", "location", "organization", "item", "vehicle",
|
||||
"creature", "structure", "concept", "other",
|
||||
)
|
||||
|
||||
# Entity lifecycle status. `DATA-MODEL.md` §9.
|
||||
ENTITY_STATUSES = ("active", "inactive", "destroyed", "dead", "unknown")
|
||||
|
||||
# Where a fact came from, in descending authority. `DATA-MODEL.md` §14 lists the
|
||||
# minimum categories; the order here is what a later context builder ranks by.
|
||||
AUTHORITIES = (
|
||||
"campaign_canon", # the campaign's own rules — the highest
|
||||
"manual_correction", # the user said so, explicitly (C04)
|
||||
"accepted_story", # derived from narration the user accepted
|
||||
"current_state",
|
||||
"imported_canon", # M7
|
||||
"reference", # M7
|
||||
"heuristic",
|
||||
"inspiration", # M7
|
||||
)
|
||||
|
||||
FACT_STATUSES = ("active", "superseded", "disputed", "invalidated")
|
||||
THREAD_STATUSES = ("open", "dormant", "resolved", "abandoned")
|
||||
RELATIONSHIP_STATUSES = ("active", "ended")
|
||||
|
||||
|
||||
def empty() -> dict:
|
||||
"""A campaign that has established nothing yet.
|
||||
|
||||
Every key is present, so no reader needs a `.get` with a default and no
|
||||
writer has to decide whether a section exists. An empty document is a real
|
||||
document, not a missing one.
|
||||
"""
|
||||
return {
|
||||
"version": VERSION,
|
||||
"entities": {},
|
||||
"possessions": {},
|
||||
"facts": [],
|
||||
"relationships": [],
|
||||
"threads": {},
|
||||
"scene": {},
|
||||
}
|
||||
|
||||
|
||||
def normalize(state) -> dict:
|
||||
"""Returns `state` as a well-formed document, repairing what it can.
|
||||
|
||||
Called on every read of a stored snapshot. A document can arrive from a
|
||||
hand-edited database, an imported bundle, or a snapshot written by an older
|
||||
version of this module, and a read must not raise on any of them: the story
|
||||
is the valuable thing, and a malformed state section should cost the
|
||||
section, not the campaign.
|
||||
|
||||
Repair is deliberately shallow — wrong-typed sections are replaced with
|
||||
empty ones rather than coerced, because guessing what a malformed section
|
||||
meant is exactly the kind of invention `§19` of the M5 brief forbids.
|
||||
"""
|
||||
if not isinstance(state, dict):
|
||||
return empty()
|
||||
out = empty()
|
||||
out["version"] = state.get("version") if isinstance(state.get("version"), int) else VERSION
|
||||
for key in ("entities", "possessions", "threads", "scene"):
|
||||
value = state.get(key)
|
||||
if isinstance(value, dict):
|
||||
out[key] = copy.deepcopy(value)
|
||||
for key in ("facts", "relationships"):
|
||||
value = state.get(key)
|
||||
if isinstance(value, list):
|
||||
out[key] = copy.deepcopy([item for item in value if isinstance(item, dict)])
|
||||
return out
|
||||
|
||||
|
||||
def is_empty(state) -> bool:
|
||||
"""Whether a document says nothing about the world.
|
||||
|
||||
`version` alone does not count as content, so a freshly created campaign
|
||||
reads as empty and the prompt builder can leave the section out entirely
|
||||
rather than showing a heading with nothing under it.
|
||||
"""
|
||||
document = normalize(state)
|
||||
return not any(
|
||||
document[key] for key in
|
||||
("entities", "possessions", "facts", "relationships", "threads", "scene")
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ entities
|
||||
|
||||
def entity(state: dict, key: str) -> dict | None:
|
||||
"""Returns the entity stored under `key`, or None."""
|
||||
entities = state.get("entities")
|
||||
if not isinstance(entities, dict):
|
||||
return None
|
||||
found = entities.get(key)
|
||||
return found if isinstance(found, dict) else None
|
||||
|
||||
|
||||
def entity_name(state: dict, key: str) -> str:
|
||||
"""The display name for `key`, falling back to the key itself.
|
||||
|
||||
A key is a slug the campaign chose, so it is readable enough to show when an
|
||||
entity was referenced before it was described.
|
||||
"""
|
||||
found = entity(state, key)
|
||||
if found and isinstance(found.get("name"), str) and found["name"].strip():
|
||||
return found["name"]
|
||||
return key
|
||||
|
||||
|
||||
def new_entity(
|
||||
*, type: str = "other", name: str = "", description: str = "",
|
||||
status: str = "active", aliases: list | None = None,
|
||||
) -> dict:
|
||||
return {
|
||||
"type": type or "other",
|
||||
"name": name,
|
||||
"description": description,
|
||||
"status": status or "active",
|
||||
"aliases": list(aliases or []),
|
||||
# Where this entity currently is, as another entity's key. None means
|
||||
# the campaign has not placed it, which is different from placing it
|
||||
# nowhere.
|
||||
"location": None,
|
||||
# Free-form condition labels: "injured", "depressurised", "asleep".
|
||||
# Labels rather than numbers, because a number implies a scale and a
|
||||
# scale implies a game system.
|
||||
"conditions": [],
|
||||
# Named values the campaign cares about. Genre-neutral by construction:
|
||||
# the campaign chooses the names, and every write is an absolute
|
||||
# assignment (ADR 010).
|
||||
"attributes": {},
|
||||
}
|
||||
|
||||
|
||||
def entities_of_type(state: dict, wanted: str) -> dict:
|
||||
"""Every entity whose `type` matches, keyed as they are stored."""
|
||||
entities = state.get("entities")
|
||||
if not isinstance(entities, dict):
|
||||
return {}
|
||||
return {
|
||||
key: value for key, value in entities.items()
|
||||
if isinstance(value, dict) and value.get("type") == wanted
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------- possessions
|
||||
|
||||
def owner_of(state: dict, item_key: str) -> str | None:
|
||||
"""Which entity holds `item_key`, or None if nobody does.
|
||||
|
||||
Possession is stored as one map from item to owner rather than as a list per
|
||||
owner, because an item has exactly one holder and the map makes that
|
||||
structural. Two owners for one item is then unrepresentable rather than
|
||||
merely invalid.
|
||||
"""
|
||||
possessions = state.get("possessions")
|
||||
if not isinstance(possessions, dict):
|
||||
return None
|
||||
owner = possessions.get(item_key)
|
||||
return owner if isinstance(owner, str) else None
|
||||
|
||||
|
||||
def held_by(state: dict, owner_key: str) -> list[str]:
|
||||
"""Every item `owner_key` currently holds, in stable order."""
|
||||
possessions = state.get("possessions")
|
||||
if not isinstance(possessions, dict):
|
||||
return []
|
||||
return sorted(
|
||||
item for item, owner in possessions.items() if owner == owner_key
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- facts
|
||||
|
||||
def withdrawn_facts(state: dict) -> list[dict]:
|
||||
"""Facts a correction or retcon took back, newest last.
|
||||
|
||||
The prompt needs these as well as the ones that stand. Dropping a withdrawn
|
||||
fact silently leaves the narration that first asserted it as the only
|
||||
account in the prompt, and the model reads surviving prose as current truth
|
||||
(M5 review, Finding 4). Naming the withdrawal is what makes the reader's
|
||||
correction win.
|
||||
"""
|
||||
facts = state.get("facts")
|
||||
if not isinstance(facts, list):
|
||||
return []
|
||||
return [
|
||||
fact for fact in facts
|
||||
if isinstance(fact, dict) and fact.get("status") == "invalidated"
|
||||
]
|
||||
|
||||
|
||||
def active_facts(state: dict) -> list[dict]:
|
||||
"""Facts that still stand, newest last.
|
||||
|
||||
An invalidated fact stays in the document rather than being removed. C04
|
||||
requires a correction to be auditable, and a fact that vanished would leave
|
||||
nothing to audit — the record of what the campaign used to believe is the
|
||||
point.
|
||||
"""
|
||||
facts = state.get("facts")
|
||||
if not isinstance(facts, list):
|
||||
return []
|
||||
return [
|
||||
fact for fact in facts
|
||||
if isinstance(fact, dict) and fact.get("status", "active") == "active"
|
||||
]
|
||||
|
||||
|
||||
def facts_about(state: dict, subject_key: str) -> list[dict]:
|
||||
return [f for f in active_facts(state) if f.get("subject") == subject_key]
|
||||
|
||||
|
||||
def knows(state: dict, subject_key: str, object_key: str) -> bool:
|
||||
"""Whether an accepted fact says `subject` knows `object`.
|
||||
|
||||
C03's question, asked the way the state model can answer it. "The campaign
|
||||
knows X" is a fact with no subject; "Mara knows X" is a fact whose subject
|
||||
is Mara. The distinction is structural, so nothing has to infer it.
|
||||
"""
|
||||
return any(
|
||||
fact.get("predicate") == "knows" and fact.get("object") == object_key
|
||||
for fact in facts_about(state, subject_key)
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------- relationships
|
||||
|
||||
def active_relationships(state: dict) -> list[dict]:
|
||||
relationships = state.get("relationships")
|
||||
if not isinstance(relationships, list):
|
||||
return []
|
||||
return [
|
||||
r for r in relationships
|
||||
if isinstance(r, dict) and r.get("status", "active") == "active"
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- threads
|
||||
|
||||
def open_threads(state: dict) -> dict:
|
||||
threads = state.get("threads")
|
||||
if not isinstance(threads, dict):
|
||||
return {}
|
||||
return {
|
||||
key: value for key, value in threads.items()
|
||||
if isinstance(value, dict) and value.get("status", "open") in ("open", "dormant")
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
"""M5: showing the narrative state — to the model, and to the reader.
|
||||
|
||||
Two audiences, one document, and they want different things. The model needs the
|
||||
state compactly, in the vocabulary it must answer in, close to where it
|
||||
generates. The reader needs it grouped and named, in the words the campaign uses.
|
||||
|
||||
Both are read-only views. Neither can change state, and the browser gets its own
|
||||
data from the API rather than from anything assembled here, because
|
||||
`BUILD-MILESTONES.md` M5 is explicit that the browser is a presentation layer and
|
||||
must not become the owner of state.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from . import model
|
||||
|
||||
# How much of a long section reaches the prompt. A campaign accumulates facts
|
||||
# faster than it accumulates anything else, and the context budget is finite;
|
||||
# the newest are the ones the current scene is most likely to need. M6 owns
|
||||
# retrieval-ranked selection, so this is deliberately a simple recency cut and
|
||||
# is documented as such rather than pretending to be a relevance model.
|
||||
PROMPT_FACTS = 30
|
||||
PROMPT_RELATIONSHIPS = 20
|
||||
PROMPT_THREADS = 12
|
||||
|
||||
|
||||
def for_prompt(state) -> str:
|
||||
"""The current state as the narrator is shown it.
|
||||
|
||||
Empty string when the campaign has established nothing, so a new story's
|
||||
prompt carries no heading with nothing under it.
|
||||
"""
|
||||
document = model.normalize(state)
|
||||
if model.is_empty(document):
|
||||
return ""
|
||||
|
||||
lines: list[str] = []
|
||||
scene = document.get("scene") or {}
|
||||
if scene.get("summary") or scene.get("location"):
|
||||
where = scene.get("location")
|
||||
head = "Scene: " + str(scene.get("summary") or "").strip()
|
||||
if where:
|
||||
head += f" (at {model.entity_name(document, where)})"
|
||||
lines.append(head.strip())
|
||||
|
||||
entities = document["entities"]
|
||||
if entities:
|
||||
lines.append("")
|
||||
lines.append("Who and what exists:")
|
||||
for key, entity in entities.items():
|
||||
lines.append(f" {key}: {_entity_line(document, key, entity)}")
|
||||
|
||||
possessions = document["possessions"]
|
||||
if possessions:
|
||||
lines.append("")
|
||||
lines.append("Held:")
|
||||
for item, owner in sorted(possessions.items()):
|
||||
lines.append(
|
||||
f" {model.entity_name(document, item)} — "
|
||||
f"{model.entity_name(document, owner)}"
|
||||
)
|
||||
|
||||
facts = model.active_facts(document)
|
||||
if facts:
|
||||
lines.append("")
|
||||
lines.append("Established:")
|
||||
for fact in facts[-PROMPT_FACTS:]:
|
||||
lines.append(f" {_fact_line(document, fact)}")
|
||||
|
||||
# What the campaign has taken back. Placed straight after what stands, so
|
||||
# the contradiction is resolved in the same breath it could be raised: the
|
||||
# story above may still narrate the moment, and this says it did not hold
|
||||
# (C04, M5 review Finding 4).
|
||||
withdrawn = model.withdrawn_facts(document)
|
||||
if withdrawn:
|
||||
lines.append("")
|
||||
lines.append("No longer true — do not treat these as established:")
|
||||
for fact in withdrawn[-PROMPT_FACTS:]:
|
||||
line = f" {_fact_line(document, fact)}"
|
||||
reason = fact.get("invalidated_reason")
|
||||
if reason:
|
||||
line += f" — {reason}"
|
||||
lines.append(line)
|
||||
|
||||
relationships = model.active_relationships(document)
|
||||
if relationships:
|
||||
lines.append("")
|
||||
lines.append("Between them:")
|
||||
for relationship in relationships[-PROMPT_RELATIONSHIPS:]:
|
||||
lines.append(
|
||||
f" {model.entity_name(document, relationship['source'])} "
|
||||
f"{relationship['type']} "
|
||||
f"{model.entity_name(document, relationship['target'])}"
|
||||
)
|
||||
|
||||
threads = model.open_threads(document)
|
||||
if threads:
|
||||
lines.append("")
|
||||
lines.append("Still open:")
|
||||
for key, thread in list(threads.items())[:PROMPT_THREADS]:
|
||||
lines.append(f" {key}: {thread.get('title', key)}")
|
||||
|
||||
return "\n".join(lines).strip()
|
||||
|
||||
|
||||
def _entity_line(document: dict, key: str, entity: dict) -> str:
|
||||
parts = [entity.get("name") or key]
|
||||
kind = entity.get("type")
|
||||
if kind and kind != "other":
|
||||
parts.append(f"({kind})")
|
||||
status = entity.get("status")
|
||||
if status and status != "active":
|
||||
parts.append(f"[{status}]")
|
||||
where = entity.get("location")
|
||||
if where:
|
||||
parts.append(f"at {model.entity_name(document, where)}")
|
||||
conditions = entity.get("conditions") or []
|
||||
if conditions:
|
||||
parts.append("— " + ", ".join(conditions))
|
||||
attributes = entity.get("attributes") or {}
|
||||
if attributes:
|
||||
parts.append(
|
||||
"— " + ", ".join(f"{name}={value}" for name, value in sorted(attributes.items()))
|
||||
)
|
||||
return " ".join(str(p) for p in parts)
|
||||
|
||||
|
||||
def _fact_line(document: dict, fact: dict) -> str:
|
||||
parts = []
|
||||
if fact.get("subject"):
|
||||
parts.append(model.entity_name(document, fact["subject"]))
|
||||
parts.append(str(fact.get("predicate", "")))
|
||||
if fact.get("object"):
|
||||
parts.append(model.entity_name(document, fact["object"]))
|
||||
if fact.get("value") is not None:
|
||||
parts.append(str(fact["value"]))
|
||||
line = " ".join(str(p) for p in parts if p)
|
||||
if fact.get("authority") == "manual_correction":
|
||||
# The reader corrected this. Saying so in the prompt is what stops the
|
||||
# model re-deriving the thing the correction removed.
|
||||
line += " [corrected by the player]"
|
||||
return line
|
||||
|
||||
|
||||
def for_inspector(state) -> dict:
|
||||
"""The current state grouped for the browser panel.
|
||||
|
||||
Only categories that actually hold something are returned, so the panel can
|
||||
render what it is given without deciding what to hide — a category with no
|
||||
rows is a heading that tells the reader nothing.
|
||||
|
||||
Every entry carries the key as well as the name. The key is what a manual
|
||||
correction has to name, so the panel can offer a correction without the user
|
||||
having to guess at an identifier.
|
||||
"""
|
||||
document = model.normalize(state)
|
||||
groups: list[dict] = []
|
||||
|
||||
scene = document.get("scene") or {}
|
||||
if scene.get("summary") or scene.get("location"):
|
||||
rows = []
|
||||
if scene.get("summary"):
|
||||
rows.append({"key": "summary", "label": str(scene["summary"])})
|
||||
if scene.get("location"):
|
||||
rows.append({
|
||||
"key": scene["location"],
|
||||
"label": model.entity_name(document, scene["location"]),
|
||||
"detail": "location",
|
||||
})
|
||||
groups.append({"title": "Current Scene", "rows": rows})
|
||||
|
||||
by_type: dict[str, list] = {}
|
||||
for key, entity in document["entities"].items():
|
||||
by_type.setdefault(entity.get("type") or "other", []).append((key, entity))
|
||||
|
||||
# Characters and locations first because they are what a reader looks for;
|
||||
# everything else in whatever categories the campaign actually used, so a
|
||||
# science-fiction campaign's `vehicle` appears without this code knowing the
|
||||
# word (J02).
|
||||
order = ["character", "location"] + sorted(
|
||||
set(by_type) - {"character", "location"}
|
||||
)
|
||||
for kind in order:
|
||||
members = by_type.get(kind)
|
||||
if not members:
|
||||
continue
|
||||
rows = []
|
||||
for key, entity in sorted(members):
|
||||
detail = []
|
||||
if entity.get("status") and entity["status"] != "active":
|
||||
detail.append(str(entity["status"]))
|
||||
if entity.get("location"):
|
||||
detail.append("at " + model.entity_name(document, entity["location"]))
|
||||
if entity.get("conditions"):
|
||||
detail.append(", ".join(entity["conditions"]))
|
||||
for name, value in sorted((entity.get("attributes") or {}).items()):
|
||||
detail.append(f"{name}: {value}")
|
||||
held = model.held_by(document, key)
|
||||
if held:
|
||||
detail.append(
|
||||
"carrying " + ", ".join(model.entity_name(document, i) for i in held)
|
||||
)
|
||||
rows.append({
|
||||
"key": key,
|
||||
"label": entity.get("name") or key,
|
||||
"detail": " · ".join(detail),
|
||||
})
|
||||
groups.append({"title": _title_for(kind), "rows": rows})
|
||||
|
||||
possessions = document["possessions"]
|
||||
if possessions:
|
||||
groups.append({"title": "Possessions", "rows": [
|
||||
{
|
||||
"key": item,
|
||||
"label": model.entity_name(document, item),
|
||||
"detail": "held by " + model.entity_name(document, owner),
|
||||
}
|
||||
for item, owner in sorted(possessions.items())
|
||||
]})
|
||||
|
||||
facts = model.active_facts(document)
|
||||
if facts:
|
||||
groups.append({"title": "Important Facts", "rows": [
|
||||
{
|
||||
"key": fact.get("id") or "",
|
||||
"label": _fact_line(document, fact),
|
||||
"detail": _source_label(fact),
|
||||
}
|
||||
for fact in facts
|
||||
]})
|
||||
|
||||
relationships = model.active_relationships(document)
|
||||
if relationships:
|
||||
groups.append({"title": "Relationships", "rows": [
|
||||
{
|
||||
"key": relationship.get("id") or "",
|
||||
"label": (
|
||||
f"{model.entity_name(document, relationship['source'])} "
|
||||
f"{relationship['type']} "
|
||||
f"{model.entity_name(document, relationship['target'])}"
|
||||
),
|
||||
"detail": relationship.get("description") or "",
|
||||
}
|
||||
for relationship in relationships
|
||||
]})
|
||||
|
||||
threads = model.open_threads(document)
|
||||
if threads:
|
||||
groups.append({"title": "Open Story Threads", "rows": [
|
||||
{
|
||||
"key": key,
|
||||
"label": thread.get("title") or key,
|
||||
"detail": thread.get("description") or "",
|
||||
}
|
||||
for key, thread in sorted(threads.items())
|
||||
]})
|
||||
|
||||
return {"groups": groups, "empty": not groups}
|
||||
|
||||
|
||||
def _title_for(kind: str) -> str:
|
||||
"""A heading for an entity category the campaign chose.
|
||||
|
||||
Pluralised generically rather than from a table, because the categories are
|
||||
open: `DATA-MODEL.md` §9 suggests nine and permits any, so a lookup would
|
||||
silently mislabel the tenth.
|
||||
"""
|
||||
known = {
|
||||
"character": "Characters",
|
||||
"location": "Locations",
|
||||
"organization": "Organizations",
|
||||
"item": "Items",
|
||||
"vehicle": "Vehicles",
|
||||
"creature": "Creatures",
|
||||
"structure": "Structures",
|
||||
"concept": "Concepts",
|
||||
"other": "Other",
|
||||
}
|
||||
if kind in known:
|
||||
return known[kind]
|
||||
word = kind.replace("_", " ").strip().title()
|
||||
return word if word.endswith("s") else word + "s"
|
||||
|
||||
|
||||
def _source_label(fact: dict) -> str:
|
||||
source = fact.get("authority") or fact.get("source") or ""
|
||||
return {
|
||||
"manual_correction": "your correction",
|
||||
"campaign_canon": "campaign canon",
|
||||
"accepted_story": "from the story",
|
||||
}.get(source, str(source).replace("_", " "))
|
||||
@@ -0,0 +1,195 @@
|
||||
"""M5: writing accepted state, atomically with the turn that caused it.
|
||||
|
||||
This is the only module in the package that touches the database, and the only
|
||||
place authoritative narrative state is written.
|
||||
|
||||
## The atomicity rule (L01)
|
||||
|
||||
Everything a turn establishes goes in one transaction: the narration, the head
|
||||
movement, the accepted events, the resulting snapshot, and the provenance. This
|
||||
function *adds* to the caller's session and never commits — the turn engine's
|
||||
single `db.commit()` remains the one commit point, so a failure anywhere before
|
||||
it rolls the whole turn back rather than leaving narration accepted with half its
|
||||
state written.
|
||||
|
||||
That ordering is deliberate and load-bearing. `L01` forbids a head position that
|
||||
implies an accepted reply whose state commit did not complete, and the cheapest
|
||||
way to guarantee that is to never have two commits to get out of step.
|
||||
|
||||
## What is not here
|
||||
|
||||
No reconstruction. Nothing in this module reads `state_events` to rebuild a
|
||||
document — the snapshot on the node is the restore path
|
||||
(`TECHNICAL-DESIGN.md` §10.4). The events are the audit trail, and an audit
|
||||
trail that the system depends on for correctness stops being an audit trail and
|
||||
becomes a replay engine.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import models
|
||||
from . import apply as apply_module
|
||||
from . import model
|
||||
|
||||
|
||||
def current(adventure: models.Adventure) -> dict:
|
||||
"""The campaign's authoritative state right now, as a document.
|
||||
|
||||
Normalised on the way out, so every caller gets the same shape whatever a
|
||||
hand-edited row or an older snapshot contains.
|
||||
"""
|
||||
return model.normalize(adventure.narrative_state)
|
||||
|
||||
|
||||
def set_current(adventure: models.Adventure, state: dict) -> None:
|
||||
adventure.narrative_state = model.normalize(state)
|
||||
|
||||
|
||||
def canon_of(adventure: models.Adventure) -> dict:
|
||||
"""The campaign's own rules, which outrank anything a narration proposes.
|
||||
|
||||
Configuration rather than code (C01, J03): the campaign says what it forbids,
|
||||
and `validate` enforces it without knowing what the rule means.
|
||||
"""
|
||||
canon = adventure.campaign_canon
|
||||
return canon if isinstance(canon, dict) else {}
|
||||
|
||||
|
||||
def record(
|
||||
db: Session,
|
||||
adventure: models.Adventure,
|
||||
*,
|
||||
review,
|
||||
raw_block: str = "",
|
||||
parsed=None,
|
||||
action: models.Action | None = None,
|
||||
branch_id: int | None = None,
|
||||
depth: int | None = None,
|
||||
model_name: str = "",
|
||||
source: str = "accepted_story",
|
||||
) -> tuple[dict, models.StateProposal]:
|
||||
"""Applies a reviewed proposal and records everything about it.
|
||||
|
||||
Returns `(new_state, proposal_row)`. The caller is responsible for putting
|
||||
the new state where it belongs — on the campaign, and on the node's snapshot
|
||||
— because only the caller knows whether this is a turn, a retry or a
|
||||
correction.
|
||||
|
||||
Nothing is committed here. See the module docstring.
|
||||
"""
|
||||
before = current(adventure)
|
||||
after = apply_module.apply_events(
|
||||
before, review.accepted, branch_id=branch_id, depth=depth, source=source
|
||||
)
|
||||
|
||||
proposal = models.StateProposal(
|
||||
adventure_id=adventure.id,
|
||||
action_id=action.id if action is not None else None,
|
||||
branch_id=branch_id,
|
||||
depth=depth,
|
||||
model_name=model_name or "",
|
||||
source=source,
|
||||
status=review.status,
|
||||
raw_output=raw_block or "",
|
||||
detail={
|
||||
"parsed": parsed,
|
||||
"accepted": review.accepted,
|
||||
"rejected": [r.as_dict() for r in review.rejected],
|
||||
},
|
||||
)
|
||||
db.add(proposal)
|
||||
# The proposal needs an id before its events can point at it, and the
|
||||
# session does not autoflush. This is a flush, not a commit: still one
|
||||
# transaction, still all-or-nothing.
|
||||
db.flush()
|
||||
|
||||
for sequence, event in enumerate(review.accepted):
|
||||
db.add(models.StateEvent(
|
||||
adventure_id=adventure.id,
|
||||
proposal_id=proposal.id,
|
||||
action_id=action.id if action is not None else None,
|
||||
branch_id=branch_id,
|
||||
depth=depth,
|
||||
sequence=sequence,
|
||||
event_type=event.get("type", ""),
|
||||
payload=copy.deepcopy(event),
|
||||
before=_before_value(before, event),
|
||||
source=source,
|
||||
))
|
||||
return after, proposal
|
||||
|
||||
|
||||
def _before_value(state: dict, event: dict) -> dict | None:
|
||||
"""What the value this event changes was, immediately beforehand.
|
||||
|
||||
Recorded per event so §8's "what was the previous value" is answerable
|
||||
without replaying anything. Only the slice the event touches: a whole
|
||||
document per event would duplicate the snapshot for no extra answer.
|
||||
"""
|
||||
kind = event.get("type")
|
||||
if kind in ("set_entity_status", "set_entity_attribute",
|
||||
"set_entity_conditions", "set_current_location"):
|
||||
entity = model.entity(state, event.get("entity", ""))
|
||||
if entity is None:
|
||||
return None
|
||||
if kind == "set_entity_status":
|
||||
return {"status": entity.get("status")}
|
||||
if kind == "set_entity_attribute":
|
||||
attribute = event.get("attribute")
|
||||
return {"attribute": attribute,
|
||||
"value": (entity.get("attributes") or {}).get(attribute)}
|
||||
if kind == "set_entity_conditions":
|
||||
return {"conditions": list(entity.get("conditions") or [])}
|
||||
return {"location": entity.get("location")}
|
||||
if kind in ("set_possession", "clear_possession"):
|
||||
return {"owner": model.owner_of(state, event.get("item", ""))}
|
||||
if kind == "invalidate_fact":
|
||||
for fact in state.get("facts") or []:
|
||||
if fact.get("id") == event.get("fact_id"):
|
||||
return {"status": fact.get("status"), "predicate": fact.get("predicate")}
|
||||
return None
|
||||
if kind == "resolve_story_thread":
|
||||
thread = (state.get("threads") or {}).get(event.get("thread", ""))
|
||||
return {"status": thread.get("status")} if isinstance(thread, dict) else None
|
||||
if kind == "end_relationship":
|
||||
return {"status": "active"}
|
||||
return None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ reading
|
||||
|
||||
def events_for(
|
||||
db: Session, adventure: models.Adventure, action_id: int
|
||||
) -> list[models.StateEvent]:
|
||||
"""The accepted events one node's narration produced, in order."""
|
||||
return (
|
||||
db.query(models.StateEvent)
|
||||
.filter(
|
||||
models.StateEvent.adventure_id == adventure.id,
|
||||
models.StateEvent.action_id == action_id,
|
||||
)
|
||||
.order_by(models.StateEvent.sequence, models.StateEvent.id)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def history(
|
||||
db: Session, adventure: models.Adventure, limit: int = 200
|
||||
) -> list[models.StateEvent]:
|
||||
"""The campaign's accepted state events, newest first.
|
||||
|
||||
Bounded by default: this is an audit view, and an unbounded read of a long
|
||||
campaign's every event is the kind of query this project keeps a regression
|
||||
test about.
|
||||
"""
|
||||
return (
|
||||
db.query(models.StateEvent)
|
||||
.filter(models.StateEvent.adventure_id == adventure.id)
|
||||
.order_by(models.StateEvent.id.desc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
@@ -0,0 +1,317 @@
|
||||
"""M5: deciding which proposed events the application will accept.
|
||||
|
||||
A proposal is untrusted model output. This module is the gate between it and the
|
||||
authoritative state, and it is layered so that a rejection can say *which* rule
|
||||
refused and a test can aim at one layer at a time:
|
||||
|
||||
1. envelope is this a proposal at all — a dict with a list of events?
|
||||
2. allowlist is each event type one this application implements? (H05)
|
||||
3. schema are the required fields present, and the right shape?
|
||||
4. referential do the entities and threads it names exist?
|
||||
5. semantic does it contradict campaign canon, or itself?
|
||||
|
||||
Layer 2 is the security boundary and runs before any field is read, so a payload
|
||||
carrying `command` or `path` alongside an unknown type is discarded without those
|
||||
fields ever being looked at.
|
||||
|
||||
## What rejection means
|
||||
|
||||
Nothing is partially applied. `review` returns accepted and rejected events
|
||||
separately and the caller decides; `apply.py` is only ever handed the accepted
|
||||
list. A proposal with one bad event out of four therefore lands three, which is
|
||||
`partially_accepted` — the alternative, discarding all four because the model
|
||||
misspelled one entity, loses story the user watched happen.
|
||||
|
||||
What is *never* allowed is a rejected event mutating anything, or a rejection
|
||||
being silent: every refusal carries a reason, is counted, and is stored on the
|
||||
proposal record for §8's audit.
|
||||
|
||||
## What this module does not do
|
||||
|
||||
It does not decide whether the model was *right*. A typed event can be
|
||||
well-formed, reference real entities, contradict nothing, and still describe
|
||||
something the narration did not say. That is C06's territory and no validator
|
||||
can settle it — ADR 010 says so plainly. What validation buys is that a wrong
|
||||
proposal is wrong in a way a person can see in the audit trail, rather than one
|
||||
that silently means something other than it appears to.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from . import events, model
|
||||
|
||||
# A rejected event carries one of these, so tests and the debug view can assert
|
||||
# on the reason rather than on prose.
|
||||
UNKNOWN_TYPE = "unknown_event_type"
|
||||
NOT_AN_OBJECT = "not_an_object"
|
||||
MISSING_FIELD = "missing_field"
|
||||
BAD_FIELD_TYPE = "bad_field_type"
|
||||
UNKNOWN_REFERENCE = "unknown_reference"
|
||||
CANON_CONFLICT = "canon_conflict"
|
||||
SELF_CONTRADICTION = "self_contradiction"
|
||||
DUPLICATE_ENTITY = "duplicate_entity"
|
||||
|
||||
# How many events one proposal may carry. A narration describes a turn, not a
|
||||
# migration; a hundred events is a runaway model or a payload trying to be
|
||||
# something else, and either way the cap bounds the work before it is done.
|
||||
MAX_EVENTS = 40
|
||||
# How long a text field may be. Long enough for a description, short enough that
|
||||
# a proposal cannot smuggle a document into the state.
|
||||
MAX_TEXT = 2_000
|
||||
MAX_LABELS = 40
|
||||
|
||||
|
||||
class Rejection:
|
||||
"""One event that will not be applied, and why."""
|
||||
|
||||
__slots__ = ("event", "reason", "detail")
|
||||
|
||||
def __init__(self, event, reason: str, detail: str = ""):
|
||||
self.event = event
|
||||
self.reason = reason
|
||||
self.detail = detail
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {"event": self.event, "reason": self.reason, "detail": self.detail}
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debugging aid
|
||||
return f"<Rejection {self.reason}: {self.detail}>"
|
||||
|
||||
|
||||
class Review:
|
||||
"""The verdict on one proposal."""
|
||||
|
||||
__slots__ = ("accepted", "rejected")
|
||||
|
||||
def __init__(self, accepted: list[dict], rejected: list[Rejection]):
|
||||
self.accepted = accepted
|
||||
self.rejected = rejected
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
"""`DATA-MODEL.md` §19's validation_status."""
|
||||
if self.rejected and self.accepted:
|
||||
return "partially_accepted"
|
||||
if self.rejected:
|
||||
return "rejected"
|
||||
return "accepted"
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {
|
||||
"status": self.status,
|
||||
"accepted": self.accepted,
|
||||
"rejected": [r.as_dict() for r in self.rejected],
|
||||
}
|
||||
|
||||
|
||||
def review(payload, state: dict, canon: dict | None = None) -> Review:
|
||||
"""Returns which of `payload`'s events may be applied to `state`.
|
||||
|
||||
`state` is the document the events would apply to, needed because
|
||||
referential checks ask what already exists. `canon` carries the campaign's
|
||||
own rules, which outrank anything a narration proposes (C01).
|
||||
|
||||
The state is **not** mutated. Events are checked against a running view that
|
||||
accounts for entities earlier events in the same proposal create, so a
|
||||
proposal may introduce Mara and then move her, but nothing is written until
|
||||
the caller applies the accepted list.
|
||||
"""
|
||||
accepted: list[dict] = []
|
||||
rejected: list[Rejection] = []
|
||||
|
||||
proposed = _events_of(payload)
|
||||
if proposed is None:
|
||||
return Review([], [Rejection(payload, NOT_AN_OBJECT,
|
||||
"the proposal is not an object with an event list")])
|
||||
|
||||
# Entities this proposal has introduced, so a later event in the same
|
||||
# proposal may refer to them. Kept separately from `state` so that a
|
||||
# rejected create cannot make a later reference resolve.
|
||||
introduced: set[str] = set()
|
||||
|
||||
for raw in proposed[:MAX_EVENTS]:
|
||||
problem = _check(raw, state, introduced, canon)
|
||||
if problem is not None:
|
||||
rejected.append(problem)
|
||||
continue
|
||||
accepted.append(raw)
|
||||
spec = events.spec(raw["type"])
|
||||
if spec and spec["creates"]:
|
||||
introduced.add(str(raw[spec["creates"]]))
|
||||
|
||||
for extra in proposed[MAX_EVENTS:]:
|
||||
rejected.append(Rejection(extra, BAD_FIELD_TYPE,
|
||||
f"more than {MAX_EVENTS} events in one proposal"))
|
||||
return Review(accepted, rejected)
|
||||
|
||||
|
||||
def _events_of(payload) -> list | None:
|
||||
"""The event list, from either shape a proposal may legitimately take."""
|
||||
if isinstance(payload, list):
|
||||
return [e for e in payload]
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
found = payload.get("events")
|
||||
if found is None:
|
||||
return []
|
||||
if not isinstance(found, list):
|
||||
return None
|
||||
return found
|
||||
|
||||
|
||||
def _check(raw, state: dict, introduced: set[str], canon: dict | None) -> Rejection | None:
|
||||
"""Returns why `raw` is unacceptable, or None if it may be applied."""
|
||||
# ---- layer 1: is it an event-shaped object at all ----
|
||||
if not isinstance(raw, dict):
|
||||
return Rejection(raw, NOT_AN_OBJECT, "event is not an object")
|
||||
|
||||
# ---- layer 2: the allowlist, before any field is read ----
|
||||
#
|
||||
# H05 lands here. `execute_shell` is refused because it is not in the
|
||||
# vocabulary, and its `command` field is never looked at — there is no
|
||||
# branch in this application that could reach it.
|
||||
event_type = raw.get("type", raw.get("event_type"))
|
||||
if not events.is_allowed(event_type):
|
||||
return Rejection(raw, UNKNOWN_TYPE, f"{event_type!r} is not a state event")
|
||||
raw["type"] = event_type
|
||||
spec = events.spec(event_type)
|
||||
|
||||
# ---- layer 3: schema ----
|
||||
for field, kind in spec["required"].items():
|
||||
if field not in raw:
|
||||
return Rejection(raw, MISSING_FIELD, f"{event_type} needs {field!r}")
|
||||
bad = _bad_shape(raw[field], kind, field)
|
||||
if bad:
|
||||
return Rejection(raw, BAD_FIELD_TYPE, bad)
|
||||
for field, kind in spec["optional"].items():
|
||||
if field in raw and raw[field] is not None:
|
||||
bad = _bad_shape(raw[field], kind, field)
|
||||
if bad:
|
||||
return Rejection(raw, BAD_FIELD_TYPE, bad)
|
||||
|
||||
# ---- layer 4: referential integrity ----
|
||||
known = set(state.get("entities") or {}) | introduced
|
||||
for field in spec["refs"]:
|
||||
named = raw.get(field)
|
||||
if named is None or field not in raw:
|
||||
continue # optional reference, absent
|
||||
if not isinstance(named, str) or named not in known:
|
||||
return Rejection(raw, UNKNOWN_REFERENCE,
|
||||
f"{event_type} names {field}={named!r}, which does not exist")
|
||||
if event_type == "add_fact" and raw.get("object") is not None:
|
||||
# An object may name an entity or another fact. Checking both keeps the
|
||||
# reference meaningful — a typo is still caught — without forcing every
|
||||
# thing a fact can be about to be promoted to an entity first.
|
||||
known_facts = {f.get("id") for f in (state.get("facts") or [])}
|
||||
target = raw["object"]
|
||||
if not isinstance(target, str) or (target not in known and target not in known_facts):
|
||||
return Rejection(raw, UNKNOWN_REFERENCE,
|
||||
f"add_fact names object={target!r}, which does not exist")
|
||||
if event_type == "invalidate_fact":
|
||||
if not any(f.get("id") == raw["fact_id"] for f in (state.get("facts") or [])):
|
||||
return Rejection(raw, UNKNOWN_REFERENCE,
|
||||
f"no fact {raw['fact_id']!r} to invalidate")
|
||||
if event_type == "resolve_story_thread":
|
||||
if raw["thread"] not in (state.get("threads") or {}):
|
||||
return Rejection(raw, UNKNOWN_REFERENCE,
|
||||
f"no story thread {raw['thread']!r} to resolve")
|
||||
if spec["creates"]:
|
||||
key = raw[spec["creates"]]
|
||||
if key in known:
|
||||
return Rejection(raw, DUPLICATE_ENTITY,
|
||||
f"{key!r} already exists; use set_* to change it")
|
||||
|
||||
# ---- layer 5: semantics ----
|
||||
return _semantic(raw, state, canon)
|
||||
|
||||
|
||||
def _bad_shape(value, kind: str, field: str) -> str | None:
|
||||
"""Returns why `value` is the wrong shape for `kind`, or None."""
|
||||
if kind in (events.TEXT, events.KEY):
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return f"{field!r} must be a non-empty string"
|
||||
if len(value) > MAX_TEXT:
|
||||
return f"{field!r} is longer than {MAX_TEXT} characters"
|
||||
return None
|
||||
if kind == events.VALUE:
|
||||
# A scalar. Explicitly not a dict or a list: a nested payload is how a
|
||||
# value field becomes somewhere to hide a second protocol.
|
||||
if not isinstance(value, (str, int, float, bool)) and value is not None:
|
||||
return f"{field!r} must be a plain value, not a structure"
|
||||
if isinstance(value, str) and len(value) > MAX_TEXT:
|
||||
return f"{field!r} is longer than {MAX_TEXT} characters"
|
||||
return None
|
||||
if kind == events.LABELS:
|
||||
if not isinstance(value, list):
|
||||
return f"{field!r} must be a list"
|
||||
if len(value) > MAX_LABELS:
|
||||
return f"{field!r} has more than {MAX_LABELS} entries"
|
||||
for item in value:
|
||||
if not isinstance(item, str) or not item.strip():
|
||||
return f"{field!r} must contain only non-empty strings"
|
||||
if len(item) > MAX_TEXT:
|
||||
return f"{field!r} contains an over-long entry"
|
||||
return None
|
||||
return f"{field!r} has an unknown field kind" # pragma: no cover
|
||||
|
||||
|
||||
def _semantic(raw: dict, state: dict, canon: dict | None) -> Rejection | None:
|
||||
"""Deterministic checks the application can actually make.
|
||||
|
||||
Deliberately modest. ADR 010 is explicit that typed events do not make a
|
||||
model correct, and pretending arbitrary fiction can be validated would be
|
||||
worse than admitting it cannot: it would produce confident rejections of
|
||||
perfectly good story. So this refuses only what the application *knows* is
|
||||
wrong — a self-contradiction, or a collision with a rule the campaign wrote
|
||||
down.
|
||||
"""
|
||||
event_type = raw["type"]
|
||||
|
||||
# An entity cannot hold itself, and cannot be in itself.
|
||||
if event_type == "set_possession" and raw["item"] == raw["owner"]:
|
||||
return Rejection(raw, SELF_CONTRADICTION, "an item cannot possess itself")
|
||||
if event_type == "set_current_location" and raw["entity"] == raw["location"]:
|
||||
return Rejection(raw, SELF_CONTRADICTION, "an entity cannot be inside itself")
|
||||
if event_type in ("add_relationship", "end_relationship") and raw["source"] == raw["target"]:
|
||||
return Rejection(raw, SELF_CONTRADICTION,
|
||||
"a relationship needs two different entities")
|
||||
|
||||
# C01: campaign canon outranks narration. The rule is generic — a campaign
|
||||
# declares transitions it forbids, and any event proposing one is refused.
|
||||
# Nothing here knows what any of those transitions mean; the campaign
|
||||
# says which it forbids, in data.
|
||||
conflict = _canon_conflict(raw, state, canon)
|
||||
if conflict is not None:
|
||||
return Rejection(raw, CANON_CONFLICT, conflict)
|
||||
return None
|
||||
|
||||
|
||||
def _canon_conflict(raw: dict, state: dict, canon: dict | None) -> str | None:
|
||||
"""Whether campaign canon forbids what this event proposes.
|
||||
|
||||
Canon is configuration, not code (J03). A campaign writes:
|
||||
|
||||
{"forbidden_status_changes": [{"from": "dead", "to": "active"}]}
|
||||
|
||||
and a narration that tries to bring a dead character back is refused —
|
||||
without this module, or any other, containing the word for what that is. A
|
||||
science-fiction campaign forbidding a different transition uses the same
|
||||
field and the same code path.
|
||||
"""
|
||||
if not isinstance(canon, dict):
|
||||
return None
|
||||
if raw["type"] != "set_entity_status":
|
||||
return None
|
||||
forbidden = canon.get("forbidden_status_changes")
|
||||
if not isinstance(forbidden, list):
|
||||
return None
|
||||
current = (model.entity(state, raw["entity"]) or {}).get("status")
|
||||
for rule in forbidden:
|
||||
if not isinstance(rule, dict):
|
||||
continue
|
||||
if rule.get("from") == current and rule.get("to") == raw["status"]:
|
||||
return (
|
||||
f"campaign canon does not allow {raw['entity']!r} to go from "
|
||||
f"{current!r} to {raw['status']!r}"
|
||||
)
|
||||
return None
|
||||
@@ -13,6 +13,8 @@ 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
|
||||
takes retries and the attempts that collect at one coordinate
|
||||
branches where a story splits
|
||||
checkpoints Save Points: durable names for positions the head can return to
|
||||
state the authoritative narrative state, and correcting it by hand
|
||||
|
||||
What this package re-exports, and what it deliberately does not:
|
||||
|
||||
@@ -31,6 +33,8 @@ from . import ( # noqa: F401
|
||||
turns,
|
||||
takes,
|
||||
branches,
|
||||
checkpoints,
|
||||
state,
|
||||
bundle_io,
|
||||
refresh,
|
||||
insights,
|
||||
|
||||
@@ -8,7 +8,8 @@ coordinate through `nodes.delete_turn`.
|
||||
from fastapi import Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ... import attempts, head, models, schemas, tree
|
||||
from ... import attempts, head, memorybank, models, narrative, schemas, tree
|
||||
from ...context import cursors, lineage
|
||||
from ...database import get_db
|
||||
|
||||
from . import turns
|
||||
@@ -60,19 +61,20 @@ def update_action(
|
||||
action = db.get(models.Action, action_id)
|
||||
if action is None or action.adventure_id != adventure_id:
|
||||
raise HTTPException(404, "Action not found")
|
||||
# An edit rewrites this row and re-evaluates nothing after it, which is what
|
||||
# makes it a correction rather than a new continuation. That is safe while
|
||||
# everything descending from the row is on screen, and unsafe the moment
|
||||
# something descends from it that is not — an undone future, or a line a
|
||||
# divergence left behind. The reader cannot see that story, so they cannot
|
||||
# see what their correction has just contradicted (M3).
|
||||
#
|
||||
# Refusing is the whole of the fix, deliberately. Making the edit fork, so
|
||||
# that the original text and its future stay whole, is
|
||||
# `STORY-BRANCH-SEMANTICS.md` §14-15 — and §15 requires re-evaluating the
|
||||
# state the edited prose implies, which is M5's extraction pass. Neither is
|
||||
# started here. What is closed is the one case where the application could
|
||||
# produce retained history that silently disagrees with itself.
|
||||
# A narrator turn the story is currently telling is corrected through the
|
||||
# §§14-15 path, which forks. A take the story is *not* telling is a
|
||||
# different thing: it has no continuation of its own — keeping one is what
|
||||
# forking is for — so correcting its words cannot contradict anything, and
|
||||
# it stays the plain in-place edit it has always been.
|
||||
if action.type == "ai" and lineage.path_of(db, adventure).contains(action):
|
||||
return _edit_narration(db, adventure, action, payload.text)
|
||||
# A player's own words. Editing one rewrites this row and re-evaluates
|
||||
# nothing after it, which is what makes it a correction rather than a new
|
||||
# continuation. That is safe while everything descending from the row is on
|
||||
# screen, and unsafe the moment something descends from it that is not — an
|
||||
# undone future, or a line a divergence left behind. The reader cannot see
|
||||
# that story, so they cannot see what their correction has just contradicted
|
||||
# (M3, `STORY-BRANCH-SEMANTICS.md` §13).
|
||||
if head.displaced_history_under(db, adventure, action):
|
||||
raise HTTPException(
|
||||
400,
|
||||
@@ -81,14 +83,172 @@ def update_action(
|
||||
"the words that story was written from. Redo to bring it back "
|
||||
"first, or play the turn again to start a new line from here.",
|
||||
)
|
||||
# One row holds one text. Nothing mirrors it now, so nothing else has to be
|
||||
# updated. The edit used to have to be written into the live variant entry
|
||||
# as well, or paging away and back reverted it.
|
||||
action.text = payload.text
|
||||
db.commit()
|
||||
turns.acquire_turn_lock(adventure_id)
|
||||
try:
|
||||
action.text = payload.text
|
||||
db.commit()
|
||||
finally:
|
||||
turns._active_turns.discard(adventure_id)
|
||||
db.refresh(action)
|
||||
return action
|
||||
|
||||
|
||||
def _edit_narration(
|
||||
db: Session, adventure: models.Adventure, action: models.Action, text: str
|
||||
) -> models.Action:
|
||||
"""Corrects narrator prose by hand, per `STORY-BRANCH-SEMANTICS.md` §§14-15.
|
||||
|
||||
A narrator edit is not a rewrite of a row. It is a continuation written from
|
||||
the same place the original was written from, using the reader's words
|
||||
instead of the model's. §15 lists what that has to mean, and each clause
|
||||
maps to a step below:
|
||||
|
||||
1. return to the state immediately before the edited narration — the
|
||||
preceding node's snapshot, one row read;
|
||||
2. treat the edited text as the accepted narrator output — it is stored
|
||||
verbatim, with only the protocol block stripped, and no model is called;
|
||||
3. re-evaluate the state that output implies — the normal M5 extraction and
|
||||
validation path, run against that starting state;
|
||||
4. create a new active continuation — a new node, and the head on it;
|
||||
5. retain the original narration and its future as disposable history —
|
||||
nothing on the old line is written to at all.
|
||||
|
||||
The M5 review found the previous implementation failing 3-5 together: it
|
||||
edited the row in place and rewound the campaign's live state to that
|
||||
position while the head stayed at the tip, so the reader saw a full
|
||||
transcript over a state document describing an earlier moment, and the
|
||||
snapshots below the edit still described prose that no longer existed
|
||||
(Finding 1). Forking is what fixes it, and no new machinery is needed to
|
||||
fork — this function is the ⑂ path from `takes.py` with the reader's text in
|
||||
place of a generated one.
|
||||
|
||||
Two shapes, chosen by whether anything was written after the turn:
|
||||
|
||||
at the tip the attempts of the turn are still leaves, so the
|
||||
correction joins them as a sibling take and the
|
||||
original is retained beside it in the pager;
|
||||
|
||||
anything below the story after the turn was written as a
|
||||
continuation of the words that are there now, so it
|
||||
keeps them: the correction leaves the path just
|
||||
before the turn and the old line keeps its node, its
|
||||
future, and its live flag.
|
||||
|
||||
The §14A refusal is gone from this path, and this is what replaces it. It
|
||||
refused an in-place edit under an off-screen future because the edit would
|
||||
silently change the words that story was written from. Nothing is changed
|
||||
now — the off-screen future keeps the exact narration it descends from — so
|
||||
the case that had to be refused is simply handled.
|
||||
"""
|
||||
if action.depth is None:
|
||||
raise HTTPException(400, "That turn is not on the story you are reading.")
|
||||
|
||||
turns.acquire_turn_lock(adventure.id)
|
||||
try:
|
||||
# §15.2. The reader's words are the narration; a block they pasted in is
|
||||
# protocol and is stripped before storage, exactly as a model's is.
|
||||
prose, parsed, raw_block = narrative.extract.split(text)
|
||||
# §15.1. Not the campaign's current state — the state this turn was
|
||||
# played from. One row read, not a replay (ADR 012).
|
||||
before = attempts.preceding(db, adventure, action)
|
||||
starting_state = (
|
||||
narrative.model.normalize(before.narrative_state_after)
|
||||
if before is not None and isinstance(before.narrative_state_after, dict)
|
||||
else narrative.model.empty()
|
||||
)
|
||||
|
||||
corrected = models.Action(
|
||||
adventure_id=adventure.id,
|
||||
type="ai",
|
||||
text=prose,
|
||||
# No model was called, so there is no prompt to show for this node.
|
||||
# In the sibling case the turn's assembled prompt moves to whichever
|
||||
# attempt is live, which is what the Insights viewer reads; in the
|
||||
# forked case the original keeps it, because the original is still
|
||||
# the live node of its own line.
|
||||
context_snapshot=None,
|
||||
)
|
||||
|
||||
tip = db_tip(db, adventure)
|
||||
# A turn the head rests on is not a leaf while a retained future
|
||||
# descends from it, and `db_tip` reads the capped path and cannot see
|
||||
# that future. Ask the head module as well (M3).
|
||||
at_the_tip = (
|
||||
tip is not None
|
||||
and tip.id == action.id
|
||||
and not head.behind_tip(db, adventure)
|
||||
)
|
||||
|
||||
if at_the_tip:
|
||||
# §15.4-5 as a take. The original stays at this coordinate as a
|
||||
# prior attempt, reachable through the pager, and the correction
|
||||
# becomes the one the story tells.
|
||||
attempts.hand_over_the_prompt(action, corrected)
|
||||
attempts.add_attempt(db, adventure, action, corrected)
|
||||
db.add(corrected)
|
||||
# The words at this coordinate changed, so anything derived from
|
||||
# them no longer describes the story.
|
||||
memorybank.forget_node(db, adventure, action)
|
||||
cursors.rewind_all(adventure, action.branch_id, action.depth - 1)
|
||||
db.flush()
|
||||
else:
|
||||
# §15.4-5 as a branch. Nothing on the departed line is written to:
|
||||
# the original node keeps its text, its live flag and every turn
|
||||
# that was played after it.
|
||||
departed = lineage.branch_of(db, adventure)
|
||||
tree.branch_at(db, adventure, action.depth - 1)
|
||||
if departed is not None:
|
||||
head.mark_superseded(departed, action.depth - 1)
|
||||
tree.place_action(db, adventure, corrected)
|
||||
db.add(corrected)
|
||||
db.flush()
|
||||
|
||||
# §15.3. The same validation path a generated turn takes, so a hand
|
||||
# -typed event is no more trusted than a model's: the allowlist, the
|
||||
# schema, the references and the canon all still apply.
|
||||
review = narrative.validate.review(
|
||||
parsed if parsed is not None else {"events": []},
|
||||
starting_state,
|
||||
narrative.store.canon_of(adventure),
|
||||
)
|
||||
# `record` writes the events and the provenance. Its returned document
|
||||
# applies them to the campaign's *current* state, which is not what an
|
||||
# edit derives from, so the document this node leaves behind is computed
|
||||
# from the turn's own starting point below.
|
||||
narrative.store.record(
|
||||
db, adventure,
|
||||
review=review,
|
||||
raw_block=raw_block,
|
||||
parsed=parsed,
|
||||
action=corrected,
|
||||
branch_id=corrected.branch_id,
|
||||
depth=corrected.depth,
|
||||
source="narrator_edit",
|
||||
)
|
||||
new_state = narrative.apply.apply_events(
|
||||
starting_state, review.accepted,
|
||||
branch_id=corrected.branch_id, depth=corrected.depth,
|
||||
source="narrator_edit",
|
||||
)
|
||||
corrected.state_changes = {
|
||||
"accepted": review.accepted,
|
||||
"rejected": [r.as_dict() for r in review.rejected],
|
||||
"summary": narrative.apply.diff(starting_state, new_state),
|
||||
}
|
||||
# The head is on the corrected node, so the campaign's live state is
|
||||
# what that node leaves behind, and the node's own snapshot is the same
|
||||
# document. That equality is the invariant the review found broken:
|
||||
# visible position == head == authoritative state.
|
||||
narrative.store.set_current(adventure, new_state)
|
||||
attempts.snapshot_outcome(adventure, corrected)
|
||||
adventure.updated_at = models.utcnow()
|
||||
db.commit()
|
||||
finally:
|
||||
turns._active_turns.discard(adventure.id)
|
||||
db.refresh(corrected)
|
||||
return corrected
|
||||
|
||||
|
||||
@router.delete("/{adventure_id}/actions/{action_id}", status_code=204)
|
||||
def delete_action(
|
||||
adventure_id: int,
|
||||
|
||||
@@ -56,6 +56,19 @@ def list_branches(
|
||||
.group_by(models.Action.branch_id)
|
||||
.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 = []
|
||||
for branch in branches:
|
||||
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
|
||||
),
|
||||
own_actions=count,
|
||||
save_points=save_points.get(branch.id, 0),
|
||||
is_head=(branch.id == adventure.head_branch_id),
|
||||
name=branch.name,
|
||||
created_at=branch.created_at,
|
||||
@@ -151,15 +165,31 @@ def delete_branch(
|
||||
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.
|
||||
|
||||
Two kinds of branch cannot be deleted. The root cannot, because it holds the
|
||||
turns every other branch borrows, so deleting it deletes the whole story. The
|
||||
branch currently being read cannot, and neither can any branch it was forked
|
||||
from, because the cascade would remove the head under the player and leave
|
||||
`head_branch_id` dangling. Switch branches first.
|
||||
Three kinds of branch cannot be deleted. The root cannot, because it holds
|
||||
the turns every other branch borrows, so deleting it deletes the whole story.
|
||||
The branch currently being read cannot, and neither can any branch it was
|
||||
forked from, because the cascade would remove the head under the player and
|
||||
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
|
||||
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)
|
||||
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 "
|
||||
"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)
|
||||
try:
|
||||
# Collect the subtree before the delete, because afterwards there is no
|
||||
@@ -199,6 +234,55 @@ def delete_branch(
|
||||
finally:
|
||||
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(
|
||||
db: Session, adventure: models.Adventure, root: models.Branch
|
||||
) -> 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)
|
||||
@@ -28,6 +28,13 @@ ACTION_LIST_COLUMNS = (
|
||||
models.Action.text,
|
||||
models.Action.reasoning,
|
||||
models.Action.world_delta,
|
||||
# M5: `world_delta`'s counterpart, and listed for exactly the reason stated
|
||||
# above it. `ActionOut.state_summary` reads it for every row on the page, so
|
||||
# leaving it out of the bulk read cost one lazy load per action — 51 rows
|
||||
# bought 53 queries (M5 review, Finding 2). It holds one turn's accepted
|
||||
# events and its summary lines, the same order of size as `world_delta`, not
|
||||
# the deferred snapshot.
|
||||
models.Action.state_changes,
|
||||
# SP9: the pager's key. If `parent_id` were deferred, every row on the page
|
||||
# would cost a lazy load, which is the cost `load_only` is here to prevent.
|
||||
# `branch_id` is listed for the same reason. The pager reads it to tell a
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
"""M5: reading the authoritative narrative state, and correcting it by hand.
|
||||
|
||||
Three endpoints, and the split between them is the point:
|
||||
|
||||
GET /state what the campaign currently believes
|
||||
POST /state/corrections the user overruling it (C04)
|
||||
GET /state/events how it came to believe that (§8's audit)
|
||||
|
||||
The browser reads the first and writes the second. It never writes state
|
||||
directly — `BUILD-MILESTONES.md` M5 is explicit that the browser is a
|
||||
presentation layer and must not become the owner of state — so a correction goes
|
||||
through the same validator, the same applier and the same event log as a
|
||||
narration does. The only difference is the `source` recorded on it, and that
|
||||
difference is the whole of C04's audit requirement.
|
||||
|
||||
The state returned here is always the state at the **active head**, because that
|
||||
is what `adventure.narrative_state` holds: head movement restores it from the
|
||||
destination node's snapshot, so an undone story is described by what was true
|
||||
then rather than by what the campaign later became.
|
||||
"""
|
||||
|
||||
from fastapi import Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ... import head, models, narrative, schemas
|
||||
from ...database import get_db
|
||||
|
||||
from . import turns
|
||||
from .deps import current_adventure, router
|
||||
|
||||
|
||||
@router.get("/{adventure_id}/state", response_model=schemas.NarrativeStateOut)
|
||||
def read_state(
|
||||
db: Session = Depends(get_db),
|
||||
adventure: models.Adventure = Depends(current_adventure),
|
||||
):
|
||||
"""The authoritative state at the position the story is being read at.
|
||||
|
||||
Grouped for display, with only the categories that actually hold something —
|
||||
a heading with no rows under it tells a reader nothing, and the panel should
|
||||
not have to decide what to hide.
|
||||
"""
|
||||
state = narrative.store.current(adventure)
|
||||
view = narrative.render.for_inspector(state)
|
||||
return schemas.NarrativeStateOut(
|
||||
groups=[schemas.StateGroup(**group) for group in view["groups"]],
|
||||
empty=view["empty"],
|
||||
# The raw document, for the correction form to name a key with and for a
|
||||
# test to assert on without parsing prose.
|
||||
document=state,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{adventure_id}/state/corrections",
|
||||
response_model=schemas.NarrativeStateOut,
|
||||
status_code=201,
|
||||
)
|
||||
def correct_state(
|
||||
adventure_id: int,
|
||||
payload: schemas.StateCorrection,
|
||||
db: Session = Depends(get_db),
|
||||
adventure: models.Adventure = Depends(current_adventure),
|
||||
):
|
||||
"""Applies the user's own state events, as an explicit correction.
|
||||
|
||||
C04. The user says "Mara never learned where the silver key was found", and
|
||||
that becomes authoritative for everything that follows — while the transcript
|
||||
stays exactly as it was written. Correcting the world is not editing the
|
||||
story, and conflating them would rewrite prose the user did not ask to
|
||||
change.
|
||||
|
||||
The events go through the **same validator** as a narration's. A user is
|
||||
trusted more than a model, but not with references that do not resolve or
|
||||
with an event type the application does not implement: a typo should be a
|
||||
clear refusal, not a corrupt document. What being trusted buys is authority —
|
||||
the resulting facts carry `manual_correction`, which outranks
|
||||
`accepted_story` when the two disagree, and which the prompt renders so the
|
||||
model is told the reader overruled it.
|
||||
|
||||
Held under the turn lock, for the reason creating a Save Point is: this reads
|
||||
the head and writes a snapshot onto the node the head rests on, and a turn in
|
||||
flight is about to move both.
|
||||
"""
|
||||
if not payload.events:
|
||||
raise HTTPException(400, "A correction needs at least one change.")
|
||||
|
||||
turns.acquire_turn_lock(adventure_id)
|
||||
try:
|
||||
state = narrative.store.current(adventure)
|
||||
review = narrative.validate.review(
|
||||
{"events": [event.model_dump(exclude_none=True) for event in payload.events]},
|
||||
state,
|
||||
narrative.store.canon_of(adventure),
|
||||
)
|
||||
if not review.accepted:
|
||||
raise HTTPException(400, _refusal_message(review))
|
||||
|
||||
node = head.node_at(db, adventure, adventure.head_depth)
|
||||
new_state, _proposal = narrative.store.record(
|
||||
db, adventure,
|
||||
review=review,
|
||||
raw_block=payload.note or "",
|
||||
parsed={"events": [e.model_dump(exclude_none=True) for e in payload.events]},
|
||||
action=node,
|
||||
branch_id=node.branch_id if node is not None else adventure.head_branch_id,
|
||||
depth=node.depth if node is not None else adventure.head_depth,
|
||||
source="manual_correction",
|
||||
)
|
||||
narrative.store.set_current(adventure, new_state)
|
||||
# The correction belongs to the position it was made at, so a later Undo
|
||||
# past it drops it and a Redo back brings it again — the same rule every
|
||||
# other state change follows. Without re-snapshotting the node, the
|
||||
# correction would survive a head movement that stepped over it.
|
||||
if node is not None:
|
||||
node.narrative_state_after = new_state
|
||||
adventure.updated_at = models.utcnow()
|
||||
db.commit()
|
||||
db.refresh(adventure)
|
||||
finally:
|
||||
turns._active_turns.discard(adventure_id)
|
||||
|
||||
view = narrative.render.for_inspector(narrative.store.current(adventure))
|
||||
return schemas.NarrativeStateOut(
|
||||
groups=[schemas.StateGroup(**group) for group in view["groups"]],
|
||||
empty=view["empty"],
|
||||
document=narrative.store.current(adventure),
|
||||
)
|
||||
|
||||
|
||||
def _refusal_message(review) -> str:
|
||||
"""Why a correction was refused, in the words the user needs.
|
||||
|
||||
The first rejection's detail, because a correction is usually one or two
|
||||
events and a wall of them helps nobody.
|
||||
"""
|
||||
if review.rejected:
|
||||
first = review.rejected[0]
|
||||
return f"That correction can't be applied — {first.detail or first.reason}."
|
||||
return "That correction can't be applied."
|
||||
|
||||
|
||||
@router.get("/{adventure_id}/state/events", response_model=list[schemas.StateEventOut])
|
||||
def read_state_events(
|
||||
limit: int = 100,
|
||||
db: Session = Depends(get_db),
|
||||
adventure: models.Adventure = Depends(current_adventure),
|
||||
):
|
||||
"""The accepted state changes, newest first: §8's audit trail.
|
||||
|
||||
What changed, which turn caused it, whether the model or the user asserted
|
||||
it, and what the value was before. Bounded by default — this is an audit
|
||||
view, and an unbounded read of a long campaign's every event is the query
|
||||
shape this project keeps a regression test about.
|
||||
"""
|
||||
limit = max(1, min(limit, 500))
|
||||
rows = narrative.store.history(db, adventure, limit=limit)
|
||||
return [
|
||||
schemas.StateEventOut(
|
||||
id=row.id,
|
||||
action_id=row.action_id,
|
||||
branch_id=row.branch_id,
|
||||
depth=row.depth,
|
||||
turn=(row.depth + 1) if row.depth is not None else None,
|
||||
sequence=row.sequence,
|
||||
event_type=row.event_type,
|
||||
payload=row.payload or {},
|
||||
before=row.before,
|
||||
source=row.source,
|
||||
created_at=row.created_at,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
@@ -13,7 +13,8 @@ from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ... import (
|
||||
attempts, head, limits, memorybank, models, schemas, tree, worldstate,
|
||||
attempts, head, limits, memorybank, models, narrative, schemas, tree,
|
||||
worldstate,
|
||||
)
|
||||
from ...context import build_context, cursors
|
||||
from ...database import get_db
|
||||
@@ -207,25 +208,46 @@ async def _generate_turn(
|
||||
yield turn_error(detail)
|
||||
return
|
||||
|
||||
# RPG world state (Phase 12): read the AI's state delta out of the reply,
|
||||
# apply it through the engine, and strip the block from the displayed text.
|
||||
# M5: read the typed state proposal out of the reply, validate it, apply
|
||||
# what survives, and strip the block from the displayed text.
|
||||
#
|
||||
# A retry re-runs the same turn, so it is played at that turn's depth. The
|
||||
# cooldown rules run on a position in the story, and a second attempt at turn
|
||||
# 12 is still turn 12. This was `retry_of.index`, which held the same number
|
||||
# until SP4. Depth stays correct once a branch has its own numbering.
|
||||
# This replaced the Phase 12 relative-delta pipeline. The shape of the turn
|
||||
# is unchanged — extract, referee, snapshot — because ADR 010 changed the
|
||||
# protocol, not the lifecycle. What changed is that the referee now works on
|
||||
# explicit typed events with absolute values, so an accepted proposal cannot
|
||||
# mean something other than it says.
|
||||
#
|
||||
# A retry re-runs the same turn, so it is played at that turn's depth. This
|
||||
# was `retry_of.index`, which held the same number until SP4. Depth stays
|
||||
# correct once a branch has its own numbering.
|
||||
ai_depth = retry_of.depth if retry_of is not None else next_depth(adventure)
|
||||
stat_schema = adventure.scenario.stat_schema if adventure.scenario else None
|
||||
if worldstate.has_schema(stat_schema):
|
||||
text, delta = worldstate.extract_delta(text)
|
||||
if not text.strip():
|
||||
yield turn_error("The AI returned only a state update and no story text.")
|
||||
return
|
||||
new_world_state, ws_report = worldstate.apply_delta(
|
||||
adventure.world_state, stat_schema, delta, ai_depth
|
||||
)
|
||||
adventure.world_state = new_world_state
|
||||
snapshot["world_state"] = {"delta": delta, "report": ws_report, "state": new_world_state}
|
||||
|
||||
text, parsed, raw_block = narrative.extract.split(text)
|
||||
if not text.strip():
|
||||
yield turn_error("The AI returned only a state update and no story text.")
|
||||
return
|
||||
review = narrative.validate.review(
|
||||
parsed if parsed is not None else {"events": []},
|
||||
narrative.store.current(adventure),
|
||||
narrative.store.canon_of(adventure),
|
||||
)
|
||||
# Held until the action exists, because a proposal record names the node
|
||||
# whose narration produced it and the node has no id yet. Everything lands
|
||||
# in the single commit below (L01).
|
||||
# The coordinate is read off the node after it is placed, not guessed here:
|
||||
# `tree.place_action` assigns the branch, and a retry inherits the branch of
|
||||
# the attempt it replaces.
|
||||
pending_state = {
|
||||
"review": review,
|
||||
"parsed": parsed,
|
||||
"raw_block": raw_block,
|
||||
"unparseable": parsed is None and bool(raw_block),
|
||||
}
|
||||
snapshot["narrative_state"] = {
|
||||
"accepted": review.accepted,
|
||||
"rejected": [r.as_dict() for r in review.rejected],
|
||||
"status": review.status,
|
||||
}
|
||||
|
||||
snapshot["raw_output"] = raw_output
|
||||
# The cost the endpoint reports for the call, including how much of the
|
||||
@@ -243,7 +265,6 @@ async def _generate_turn(
|
||||
context_snapshot=snapshot,
|
||||
world_delta=world_delta_of(snapshot),
|
||||
)
|
||||
attempts.snapshot_outcome(adventure, ai_action)
|
||||
if retry_of is not None:
|
||||
attempts.add_attempt(db, adventure, retry_of, ai_action)
|
||||
db.add(ai_action)
|
||||
@@ -264,6 +285,33 @@ async def _generate_turn(
|
||||
else:
|
||||
tree.place_action(db, adventure, ai_action)
|
||||
db.add(ai_action)
|
||||
db.flush()
|
||||
# The state lands after the node exists and before the one commit, so the
|
||||
# narration, the head, the accepted events, the provenance and the snapshot
|
||||
# are one transaction. L01 forbids any window in which a turn looks accepted
|
||||
# while its state is half-written, and the cheapest guarantee is to have a
|
||||
# single commit rather than two that could get out of step.
|
||||
new_state, _proposal = narrative.store.record(
|
||||
db, adventure,
|
||||
review=pending_state["review"],
|
||||
raw_block=pending_state["raw_block"],
|
||||
parsed=pending_state["parsed"],
|
||||
action=ai_action,
|
||||
branch_id=ai_action.branch_id,
|
||||
depth=ai_action.depth,
|
||||
model_name=settings.model or "",
|
||||
source="accepted_story",
|
||||
)
|
||||
if pending_state["unparseable"]:
|
||||
_proposal.status = "unparseable"
|
||||
before_state = narrative.store.current(adventure)
|
||||
narrative.store.set_current(adventure, new_state)
|
||||
ai_action.state_changes = {
|
||||
"accepted": pending_state["review"].accepted,
|
||||
"rejected": [r.as_dict() for r in pending_state["review"].rejected],
|
||||
"summary": narrative.apply.diff(before_state, new_state),
|
||||
}
|
||||
attempts.snapshot_outcome(adventure, ai_action)
|
||||
adventure.updated_at = models.utcnow()
|
||||
db.commit()
|
||||
db.refresh(ai_action)
|
||||
|
||||
+148
-1
@@ -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).
|
||||
PERSONA_NAME_MAX = 80 # The protagonist's name. VARCHAR(80).
|
||||
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)]
|
||||
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)]
|
||||
PersonaName = Annotated[str, Field(max_length=PERSONA_NAME_MAX)]
|
||||
PersonaPronouns = Annotated[str, Field(max_length=PERSONA_PRONOUNS_MAX)]
|
||||
CheckpointName = Annotated[str, Field(max_length=CHECKPOINT_NAME_MAX)]
|
||||
|
||||
|
||||
class ORMModel(BaseModel):
|
||||
@@ -199,8 +204,13 @@ class ActionOut(ORMModel):
|
||||
text: str
|
||||
reasoning: str | None = None
|
||||
# Phase 12: the compact RPG state changes for this turn, read from the
|
||||
# model property.
|
||||
# model property. Legacy as of M5 and empty on new turns; kept so a pre-M5
|
||||
# campaign's chips still render.
|
||||
world_changes: list[dict] = []
|
||||
# M5: what this turn changed, as short lines for the chip under an AI
|
||||
# message. Read from `Action.state_summary`, which reads the small
|
||||
# bulk-loaded column rather than the deferred snapshot.
|
||||
state_summary: list[str] = []
|
||||
# SP9: the pager, such as `2/4`. It reports how many attempts this turn has
|
||||
# and which one is on screen. It is keyed on the parent, so it counts the
|
||||
# attempts of this turn rather than every node that shares a depth, and it
|
||||
@@ -254,6 +264,10 @@ class BranchOut(ORMModel):
|
||||
fork_depth: int | None = None
|
||||
depth: int
|
||||
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
|
||||
# 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.
|
||||
@@ -267,6 +281,139 @@ class BranchRename(BaseModel):
|
||||
name: Annotated[str, Field(max_length=BRANCH_NAME_MAX)] | None = None
|
||||
|
||||
|
||||
# ---------- Narrative state (M5) ----------
|
||||
|
||||
|
||||
class StateGroup(BaseModel):
|
||||
"""One labelled section of the state inspector.
|
||||
|
||||
Rows carry the key as well as the label, because a manual correction has to
|
||||
name an entity and the user should not have to guess the identifier.
|
||||
"""
|
||||
|
||||
title: str
|
||||
rows: list[dict] = []
|
||||
|
||||
|
||||
class NarrativeStateOut(BaseModel):
|
||||
"""The authoritative state at the active head.
|
||||
|
||||
`groups` is the display form and `document` is the state itself. Both are
|
||||
returned because they answer different questions: the panel renders the
|
||||
first, and a correction form — or a test — needs the second to name a key.
|
||||
"""
|
||||
|
||||
groups: list[StateGroup] = []
|
||||
empty: bool = True
|
||||
document: dict = {}
|
||||
|
||||
|
||||
class StateEventIn(BaseModel):
|
||||
"""One typed event, as a client proposes it.
|
||||
|
||||
Deliberately loose about which fields are present: the event vocabulary is
|
||||
defined in `narrative/events.py` and enforced by `narrative/validate.py`,
|
||||
and duplicating those rules here would create a second, drifting copy of the
|
||||
allowlist. What this model does is bound the shapes — a type that is a
|
||||
string, values that are scalars, labels that are short strings — so a
|
||||
payload cannot smuggle a structure past Pydantic and reach the validator as
|
||||
something other than an event.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
type: Annotated[str, Field(max_length=60)]
|
||||
|
||||
|
||||
class StateCorrection(BaseModel):
|
||||
"""A manual correction: the user overruling what the story established.
|
||||
|
||||
`note` records why, in the user's words, and is kept on the proposal record
|
||||
so the audit says more than "the user changed this".
|
||||
"""
|
||||
|
||||
events: Annotated[list[StateEventIn], Field(min_length=1, max_length=20)]
|
||||
note: Prose = ""
|
||||
|
||||
|
||||
class StateEventOut(ORMModel):
|
||||
"""One accepted change, for the audit view."""
|
||||
|
||||
id: int
|
||||
action_id: int | None = None
|
||||
branch_id: int | None = None
|
||||
depth: int | None = None
|
||||
# The reader-facing position, matching the Save Point panel's vocabulary.
|
||||
turn: int | None = None
|
||||
sequence: int = 0
|
||||
event_type: str
|
||||
payload: dict = {}
|
||||
before: dict | None = None
|
||||
source: str = "accepted_story"
|
||||
created_at: datetime
|
||||
|
||||
|
||||
# ---------- 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):
|
||||
text: ActionText
|
||||
|
||||
|
||||
@@ -348,6 +348,23 @@ def stamp_outcome(adventure: models.Adventure, action: models.Action) -> None:
|
||||
if action.world_state_after is None:
|
||||
world = adventure.world_state if isinstance(adventure.world_state, dict) else {}
|
||||
action.world_state_after = copy.deepcopy(world)
|
||||
if action.narrative_state_after is None:
|
||||
# M5, and the same rule: a node with no narrative snapshot is a position
|
||||
# the head cannot be restored to, and the failure is silent — the state
|
||||
# simply stays where it was. A campaign's opening node is written by the
|
||||
# fixture that creates the adventure rather than by the turn engine, so
|
||||
# without this it would be the one position Undo could not return to.
|
||||
#
|
||||
# An empty document rather than NULL, because this node is being written
|
||||
# *now*, by a writer that knows the campaign has no state yet. That is
|
||||
# different from a pre-M5 row, whose NULL means "there was no such thing
|
||||
# as narrative state when this played" and must leave the live state
|
||||
# alone.
|
||||
from .narrative import model as narrative_model
|
||||
narrative = adventure.narrative_state
|
||||
action.narrative_state_after = copy.deepcopy(
|
||||
narrative if isinstance(narrative, dict) else narrative_model.empty()
|
||||
)
|
||||
|
||||
|
||||
def place_new_nodes(session: Session) -> None:
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""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 TALLY_PER_TURN, tally_reply # noqa: E402
|
||||
|
||||
_turn = itertools.count(1)
|
||||
|
||||
|
||||
class DeterministicProvider:
|
||||
"""Records a running tally per reply, numbered so the 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):
|
||||
n = next(_turn)
|
||||
# An absolute running total (M5, ADR 010): turn n states n * 10, so the
|
||||
# value a position holds is a fact about that position rather than about
|
||||
# how many times something was added.
|
||||
yield ("text", tally_reply(f"Beat {n}.", n * TALLY_PER_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")
|
||||
+101
-25
@@ -5,6 +5,7 @@ their own `ScriptedProvider`, and the copies had drifted into four different
|
||||
feature sets, so a test that needed to raise a provider error had to be written
|
||||
in one of the files whose copy supported it.
|
||||
"""
|
||||
import json
|
||||
|
||||
|
||||
class ScriptedProvider:
|
||||
@@ -48,35 +49,110 @@ class ScriptedProvider:
|
||||
# that a rollback failure is arithmetic rather than a judgement call: if a take
|
||||
# stacks instead of replacing, the total is off by exactly one turn's worth.
|
||||
#
|
||||
# That instrumentation used to be a JavaScript `output` hook doing
|
||||
# `state.gold += 10` in the QuickJS sandbox. M2 removed campaign scripting, and
|
||||
# the tests below are not about scripting — they are about the state snapshot,
|
||||
# rollback, and branch-isolation machinery in `attempts.py` and `tree.py`,
|
||||
# which is unchanged.
|
||||
# The instrument has moved twice, and both moves were the same move: it follows
|
||||
# whatever the production state path is, so the tests exercise real code rather
|
||||
# than a test hook. It began as a QuickJS `state.gold += 10` (removed with
|
||||
# scripting in M2), became an RPG world-state delta block (M3/M4), and is now a
|
||||
# typed narrative-state event (M5).
|
||||
#
|
||||
# The counter therefore moved to the world-state engine, which is a real
|
||||
# remaining product path: the model emits a ```state delta block, the referee
|
||||
# applies it, and the result lands in `adventure.world_state`. The fake
|
||||
# provider decides what the model "emits", so it is exactly as deterministic as
|
||||
# the script was, and it exercises production code rather than a test hook.
|
||||
# What the tests using it measure is unchanged, and worth restating because it
|
||||
# is why they were re-instrumented rather than deleted: the state at a story
|
||||
# position, rollback, Redo restoration, retry, alternate takes, divergence,
|
||||
# abandoned-future isolation, and Save Point restore. None of that was ever
|
||||
# about gold, or about RPG stats.
|
||||
#
|
||||
# The M5 instrument is deliberately genre-neutral: a `chronicle` entity — a
|
||||
# concept, not a character, not an item — carrying one named attribute. Every
|
||||
# reply sets it to an ABSOLUTE total, which is ADR 010's whole point. A delta
|
||||
# protocol could not tell "+10" from "= 10"; here the event type says which, so
|
||||
# `TALLY_PER_TURN * n` after n turns is arithmetic rather than an assumption.
|
||||
|
||||
#: A schema with a plain unbounded counter. No `max_delta_per_turn` and no
|
||||
#: `cooldown`, so every +10 is applied in full, every turn.
|
||||
GOLD_SCHEMA = {
|
||||
"player": {
|
||||
"hp": {"min": 0, "max": 100, "initial": 100},
|
||||
"gold": {"min": 0, "max": 1_000_000, "initial": 0},
|
||||
}
|
||||
}
|
||||
#: The instrument is a fact, not an entity attribute, and deliberately so.
|
||||
#: `set_entity_attribute` names an entity that must already exist, which is the
|
||||
#: right rule for the product and the wrong one for an instrument that tests
|
||||
#: script in isolation — a one-off reply in the middle of a test would be
|
||||
#: refused for a reference the test never meant to be about. `add_fact` needs no
|
||||
#: subject, so any reply can state the tally on its own. Entity creation,
|
||||
#: possession and the referential rule get their own tests in
|
||||
#: `test_narrative_state.py`, where they are the subject rather than scaffolding.
|
||||
TALLY_PREDICATE = "tally"
|
||||
TALLY_PER_TURN = 10
|
||||
|
||||
GOLD_PER_TURN = 10
|
||||
# Kept as an alias so the many tests that speak in these terms keep reading
|
||||
# naturally. The number is the same; only the protocol underneath changed.
|
||||
GOLD_PER_TURN = TALLY_PER_TURN
|
||||
|
||||
#: A scenario schema is no longer needed for state to work — narrative state is
|
||||
#: not an opt-in RPG layer. The name survives for fixtures that still pass
|
||||
#: something, and empty is the honest value: this campaign has no RPG layer, and
|
||||
#: under M5 it does not need one to have state.
|
||||
GOLD_SCHEMA: dict = {}
|
||||
|
||||
|
||||
def gold_reply(text: str, amount: int = GOLD_PER_TURN) -> str:
|
||||
"""A model reply that narrates `text` and banks `amount` gold."""
|
||||
return f'{text}\n```state\n{{"player.gold": {amount}}}\n```'
|
||||
def state_block(events: list) -> str:
|
||||
"""The fenced block the model is asked to emit, around `events`."""
|
||||
return "```state\n" + json.dumps({"events": events}, ensure_ascii=False) + "\n```"
|
||||
|
||||
|
||||
def gold_replies(prefix: str = "Take", count: int = 40) -> list[str]:
|
||||
"""`count` numbered replies, each banking one turn's worth of gold."""
|
||||
return [gold_reply(f"{prefix} {n}.") for n in range(1, count + 1)]
|
||||
def tally_reply(text: str, total: int) -> str:
|
||||
"""A reply that narrates `text` and records the tally as `total`.
|
||||
|
||||
Absolute, always — which is the whole of ADR 010. A delta protocol could not
|
||||
tell "+10" from "= 10"; here the event says which, so `TALLY_PER_TURN * n`
|
||||
after n turns is arithmetic rather than an assumption, and a replayed or
|
||||
duplicated reply cannot silently double it.
|
||||
|
||||
Each reply supersedes the last, so the newest active tally fact is the
|
||||
current one and the document does not grow without bound.
|
||||
"""
|
||||
return f"{text}\n" + state_block([{
|
||||
"type": "add_fact",
|
||||
"predicate": TALLY_PREDICATE,
|
||||
"value": total,
|
||||
"fact_id": f"tally-{total}",
|
||||
}])
|
||||
|
||||
|
||||
def gold_reply(text: str, amount: int = TALLY_PER_TURN) -> str:
|
||||
"""One reply banking `amount`, for tests that build a single reply.
|
||||
|
||||
The value is absolute underneath, so a caller asking for the default gets
|
||||
the first turn's total, which is what those call sites mean.
|
||||
"""
|
||||
return tally_reply(text, amount)
|
||||
|
||||
|
||||
def tally_replies(prefix: str = "Take", count: int = 40) -> list:
|
||||
"""`count` numbered replies whose tally runs 10, 20, 30 …"""
|
||||
return [
|
||||
tally_reply(f"{prefix} {n}.", n * TALLY_PER_TURN)
|
||||
for n in range(1, count + 1)
|
||||
]
|
||||
|
||||
|
||||
#: The historical name, unchanged in meaning for every caller.
|
||||
gold_replies = tally_replies
|
||||
|
||||
|
||||
def tally_of(state) -> int:
|
||||
"""Reads the instrument back out of a narrative state document.
|
||||
|
||||
The newest active tally fact wins, which is what "absolute assignment"
|
||||
means when the assignments are appended. Returns 0 when the campaign has
|
||||
recorded none — what "no turns have been played" means, and what a restore
|
||||
to before the first turn should produce.
|
||||
"""
|
||||
if not isinstance(state, dict):
|
||||
return 0
|
||||
facts = state.get("facts")
|
||||
if not isinstance(facts, list):
|
||||
return 0
|
||||
for fact in reversed(facts):
|
||||
if (
|
||||
isinstance(fact, dict)
|
||||
and fact.get("predicate") == TALLY_PREDICATE
|
||||
and fact.get("status", "active") == "active"
|
||||
and isinstance(fact.get("value"), (int, float))
|
||||
):
|
||||
return fact["value"]
|
||||
return 0
|
||||
|
||||
@@ -22,7 +22,7 @@ from app.database import Base, SessionLocal, engine, get_db
|
||||
from app.main import app
|
||||
from app.routers import adventures
|
||||
|
||||
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply
|
||||
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply, tally_of, tally_reply
|
||||
|
||||
# `hp` moves freely. `mana` has a cooldown of 2 turns, so an incorrect
|
||||
# advance shows up as a change the referee should have rejected.
|
||||
@@ -109,10 +109,17 @@ def _fork(client, action_id):
|
||||
|
||||
|
||||
def _state(adv_id):
|
||||
"""The instrument, and the whole document behind it.
|
||||
|
||||
M5 moved the instrument from an RPG stat to a typed narrative fact; the
|
||||
tuple shape is kept so the call sites read the same. `[0]["gold"]` is the
|
||||
tally, and `[1]` is the authoritative state document.
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
adv = db.get(models.Adventure, adv_id)
|
||||
return (adv.world_state or {}).get("player", {}), adv.world_state
|
||||
state = adv.narrative_state or {}
|
||||
return {"gold": tally_of(state)}, state
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -314,71 +321,76 @@ def test_forking_a_live_node_on_another_branch_is_refused(client):
|
||||
# -------------------------------------------------------------- the state
|
||||
|
||||
def test_switching_restores_the_state_a_branch_left_behind(client):
|
||||
# Two stats move: hp differs per attempt, and gold counts turns. Between
|
||||
# them, a switch that restored the wrong snapshot is visible either way.
|
||||
"""Each attempt records its own total, so a switch that restored the wrong
|
||||
snapshot shows a number no position on that line ever held."""
|
||||
ScriptedProvider.replies = [
|
||||
'A scratch.\n```state\n{"player.hp": -5, "player.gold": 10}\n```',
|
||||
'A beating.\n```state\n{"player.hp": -40, "player.gold": 10}\n```',
|
||||
gold_reply("Onward."),
|
||||
tally_reply("A scratch.", 10),
|
||||
tally_reply("A beating.", 40),
|
||||
tally_reply("Onward.", 70),
|
||||
]
|
||||
_play(client)
|
||||
_retry(client)
|
||||
_play(client, "go deeper")
|
||||
parent = _branches(client)[0]["id"]
|
||||
on_parent = _state(client.adv_id)
|
||||
assert on_parent[0]["gold"] == 70
|
||||
|
||||
discarded = [a.id for a in _rows(client.adv_id) if a.type == "ai" and not a.live][0]
|
||||
_fork(client, discarded)
|
||||
player, world_state = _state(client.adv_id)
|
||||
assert world_state["player"]["hp"] == 95, "the attempt this branch tells"
|
||||
assert player["gold"] == 10, "one turn of gold, not three"
|
||||
player, _document = _state(client.adv_id)
|
||||
assert player["gold"] == 10, "the attempt this branch tells, not the line it left"
|
||||
|
||||
client.post(f"/api/adventures/{client.adv_id}/branches/{parent}/switch")
|
||||
assert _state(client.adv_id) == on_parent
|
||||
|
||||
|
||||
def test_the_cooldown_clock_travels_with_the_branch(client):
|
||||
"""The world-state clock is a depth, and depths repeat across branches,
|
||||
so it can only be correct if each branch carries its own. It does,
|
||||
without extra work: the clock lives inside `_meta.last_changed`, which
|
||||
is part of the world state a switch restores."""
|
||||
def test_state_travels_with_the_branch(client):
|
||||
"""Each line carries its own state, and a switch restores that line's.
|
||||
|
||||
This was written about the RPG cooldown clock, which was a depth stored
|
||||
inside the world state — and depths repeat across branches, so the clock
|
||||
could only be right if each branch carried its own. M5 removed that
|
||||
machinery; the property it demonstrated is general and still holds, because
|
||||
a branch's state is whatever its own tip recorded.
|
||||
"""
|
||||
ScriptedProvider.replies = [
|
||||
"Drained.\n```state\n{\"player.mana\": -10}\n```",
|
||||
"Untouched.",
|
||||
"Onward.",
|
||||
tally_reply("Drained.", 10),
|
||||
tally_reply("Untouched.", 20),
|
||||
tally_reply("Onward.", 30),
|
||||
]
|
||||
_play(client)
|
||||
_retry(client)
|
||||
_play(client, "go deeper")
|
||||
discarded = [a.id for a in _rows(client.adv_id) if a.type == "ai" and not a.live][0]
|
||||
on_parent = _state(client.adv_id)[1]
|
||||
assert on_parent["_meta"]["last_changed"].get("player.mana") is None
|
||||
on_parent = _state(client.adv_id)
|
||||
assert on_parent[0]["gold"] == 30
|
||||
|
||||
_fork(client, discarded)
|
||||
forked = _state(client.adv_id)[1]
|
||||
assert forked["player"]["mana"] == 40
|
||||
assert forked["_meta"]["last_changed"]["player.mana"] == 2
|
||||
assert _state(client.adv_id)[0]["gold"] == 10, "the forked line's own state"
|
||||
|
||||
parent = [b for b in _branches(client) if b["parent_branch_id"] is None][0]["id"]
|
||||
client.post(f"/api/adventures/{client.adv_id}/branches/{parent}/switch")
|
||||
assert _state(client.adv_id)[1] == on_parent
|
||||
assert _state(client.adv_id) == on_parent
|
||||
|
||||
|
||||
def test_a_retry_does_not_advance_the_cooldown_clock(client):
|
||||
"""SP5's one carried-over open item. A retry re-runs the same turn, so
|
||||
the clock the cooldown rules read must not move. The reused `index`
|
||||
used to guarantee this; the reused depth guarantees it now."""
|
||||
ScriptedProvider.replies = [
|
||||
"Drained.\n```state\n{\"player.mana\": -10}\n```",
|
||||
"Drained again.\n```state\n{\"player.mana\": -10}\n```",
|
||||
]
|
||||
def test_a_retry_reuses_the_turns_coordinate_and_does_not_stack(client):
|
||||
"""SP5's carried-over item, restated for M5.
|
||||
|
||||
A retry re-runs the same turn, so it lands at that turn's coordinate and its
|
||||
state replaces rather than accumulates. The original form of this test
|
||||
measured it through the cooldown clock, which read a depth; the depth is
|
||||
still what makes it true, and the state document is now where it shows.
|
||||
"""
|
||||
ScriptedProvider.replies = [tally_reply("Drained.", 10)]
|
||||
_play(client)
|
||||
first = _state(client.adv_id)[1]["_meta"]["last_changed"]["player.mana"]
|
||||
first = [a for a in _rows(client.adv_id) if a.type == "ai" and a.live][0]
|
||||
|
||||
ScriptedProvider.replies = [tally_reply("Drained again.", 10)]
|
||||
_retry(client)
|
||||
assert _state(client.adv_id)[1]["_meta"]["last_changed"]["player.mana"] == first
|
||||
# The second attempt's drain must land, instead of being rejected for a
|
||||
# cooldown it was never actually subject to.
|
||||
assert _state(client.adv_id)[1]["player"]["mana"] == 40
|
||||
|
||||
live = [a for a in _rows(client.adv_id) if a.type == "ai" and a.live][0]
|
||||
assert live.depth == first.depth, "the retry moved the turn's coordinate"
|
||||
assert _state(client.adv_id)[0]["gold"] == 10, "the retry stacked instead of replacing"
|
||||
|
||||
|
||||
# --------------------------------------------------------- derived work
|
||||
|
||||
@@ -33,7 +33,7 @@ from app.database import Base, SessionLocal, engine, get_db
|
||||
from app.main import app
|
||||
from app.routers import adventures
|
||||
|
||||
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply
|
||||
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply, tally_of, tally_reply
|
||||
|
||||
SCHEMA = GOLD_SCHEMA
|
||||
|
||||
@@ -170,10 +170,11 @@ def _branch_rows(adv_id) -> list[models.Branch]:
|
||||
db.close()
|
||||
|
||||
|
||||
def _script_state(adv_id) -> dict:
|
||||
def _tally(adv_id) -> int:
|
||||
"""The narrative-state instrument, as it stands at the active head."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
return (db.get(models.Adventure, adv_id).world_state or {}).get("player", {})
|
||||
return tally_of(db.get(models.Adventure, adv_id).narrative_state)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -250,29 +251,29 @@ def test_the_head_comes_back_on_the_branch_it_was_left_on(client):
|
||||
def test_a_switch_in_the_copy_restores_what_that_branch_left_behind(client):
|
||||
"""This test justifies why the bundle carries after-snapshots.
|
||||
|
||||
The gold script adds ten a turn, so the stored gold total counts the
|
||||
turns behind it. A bundle that carried the actions but not the
|
||||
outcomes would import a tree that reads correctly but switches to the
|
||||
wrong state.
|
||||
Each line ends on its own recorded total. A bundle that carried the actions
|
||||
but not the outcomes would import a tree that reads correctly and then
|
||||
switches to the wrong state — which is exactly what M5's snapshot column
|
||||
had to be added to the bundle to prevent.
|
||||
"""
|
||||
original = _forked_story(client)
|
||||
# Play one more turn on the fork, so the two tips end up at
|
||||
# genuinely different totals. Turn for turn, both branches earn the
|
||||
# same gold, so a switch that restored nothing would still look right.
|
||||
ScriptedProvider.replies = [gold_reply("Further still.")]
|
||||
# Play one more turn on the fork, so the two tips end up at genuinely
|
||||
# different totals. If both lines ended on the same number, a switch that
|
||||
# restored nothing would still look right.
|
||||
ScriptedProvider.replies = [tally_reply("Further still.", 70)]
|
||||
_play(client, original, "press on")
|
||||
|
||||
per_branch = []
|
||||
for branch in _branches(client, original):
|
||||
_switch(client, original, branch["id"])
|
||||
per_branch.append(_script_state(original).get("gold"))
|
||||
per_branch.append(_tally(original))
|
||||
assert len(set(per_branch)) == len(per_branch), "the tips are at different totals"
|
||||
|
||||
copy = _imported(client, _export(client, original))
|
||||
restored = []
|
||||
for branch in _branches(client, copy):
|
||||
_switch(client, copy, branch["id"])
|
||||
restored.append(_script_state(copy).get("gold"))
|
||||
restored.append(_tally(copy))
|
||||
assert restored == per_branch
|
||||
|
||||
|
||||
|
||||
@@ -207,29 +207,67 @@ def test_the_demo_asks_for_the_turn_counter():
|
||||
# What the model is told about its own refused changes
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def test_history_replays_what_was_accepted_not_what_was_sent():
|
||||
"""The contradiction that taught the model to repeat itself.
|
||||
def test_history_replays_prose_without_the_protocol_block():
|
||||
"""M5 corrective pass (review Finding 4): replayed history is prose only.
|
||||
|
||||
`arrows` is at its ceiling, so `+2` changes nothing. Replaying the sent
|
||||
delta showed the model a change the live values disagreed with.
|
||||
The block used to be reconstructed into each past AI turn so the model would
|
||||
copy the output format. That put a second, older account of the world into
|
||||
the same prompt as the authoritative one with nothing marking which
|
||||
governed — and a fact the reader had explicitly withdrawn came back as an
|
||||
accepted event, phrased as the model first asserted it. The format
|
||||
instruction survives in `EMIT_RULE` and `EMIT_REMINDER`; the contradiction
|
||||
does not.
|
||||
"""
|
||||
from app.context.builder import _history_text
|
||||
|
||||
a = action({"player.arrows": 2, "player.hp": -10})
|
||||
a.text = "The arrow flies."
|
||||
a = models.Action(
|
||||
type="ai",
|
||||
text="The arrow flies.",
|
||||
state_changes={
|
||||
"accepted": [{"type": "add_fact", "predicate": "the arrow struck"}],
|
||||
"rejected": [{"event": {"type": "set_possession", "item": "ghost",
|
||||
"owner": "mara"},
|
||||
"reason": "unknown_reference", "detail": "no ghost"}],
|
||||
"summary": ["fact: the arrow struck"],
|
||||
},
|
||||
)
|
||||
replayed = _history_text(a)
|
||||
assert '"player.hp": -10' in replayed
|
||||
assert "arrows" not in replayed
|
||||
assert replayed == "The arrow flies."
|
||||
assert "```state" not in replayed
|
||||
assert "add_fact" not in replayed
|
||||
# Neither the accepted event nor the refused one is asserted again.
|
||||
assert "ghost" not in replayed
|
||||
|
||||
|
||||
def test_history_replay_keeps_flags_and_milestones_and_text():
|
||||
def test_history_replay_carries_no_machine_readable_payload():
|
||||
"""Whatever a turn accepted, the history the model reads is the story."""
|
||||
from app.context.builder import _history_text
|
||||
|
||||
a = action({"flags.has_key": True, "milestones.rescue_gwen": True})
|
||||
a.text = "The lock gives."
|
||||
a = models.Action(
|
||||
type="ai",
|
||||
text="The lock gives.",
|
||||
state_changes={
|
||||
"accepted": [
|
||||
{"type": "open_story_thread", "thread": "the-vault",
|
||||
"title": "Open the vault"},
|
||||
],
|
||||
"rejected": [],
|
||||
"summary": [],
|
||||
},
|
||||
)
|
||||
replayed = _history_text(a)
|
||||
assert '"flags.has_key": true' in replayed
|
||||
assert '"milestones.rescue_gwen": true' in replayed
|
||||
assert replayed == "The lock gives."
|
||||
assert "open_story_thread" not in replayed
|
||||
assert "the-vault" not in replayed
|
||||
|
||||
|
||||
def test_a_turn_that_changed_nothing_replays_as_prose_alone():
|
||||
"""An empty block in the replayed history reads as a turn worth reporting
|
||||
nothing about, which is not the same as a turn that reported nothing."""
|
||||
from app.context.builder import _history_text
|
||||
|
||||
a = models.Action(type="ai", text="Silence.", state_changes=None)
|
||||
assert _history_text(a) == "Silence."
|
||||
|
||||
|
||||
def test_a_refusal_reaches_the_model_with_the_valid_names():
|
||||
|
||||
@@ -23,7 +23,7 @@ from app import auth, limits, models
|
||||
from app.database import Base, SessionLocal, engine, get_db
|
||||
from app.main import app
|
||||
from app.routers import adventures
|
||||
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply
|
||||
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply, tally_of, tally_reply
|
||||
|
||||
# `mana` carries a cooldown, so a clock that was not rolled back shows up as
|
||||
# a refusal rather than as a number that is merely off.
|
||||
@@ -41,9 +41,13 @@ SCHEMA = {
|
||||
# Ten gold a turn. A total that only ever climbs makes a missing rollback
|
||||
# obvious: it is off by exactly one turn's worth.
|
||||
|
||||
DRAIN = 'Drained.\n```state\n{"player.mana": -10}\n```'
|
||||
# The same turn, also banking the per-turn counter the rollback tests measure.
|
||||
DRAIN_AND_GOLD = 'Drained.\n```state\n{"player.mana": -10, "player.gold": 10}\n```'
|
||||
# The instrument is a typed narrative fact with an absolute value (M5,
|
||||
# ADR 010). It was an RPG mana drain plus a gold counter; what these tests
|
||||
# measure — that deleting a turn puts the state back to what the position
|
||||
# before it left behind — is unchanged, and is now measured through the
|
||||
# production state path rather than through a removed game system.
|
||||
DRAIN = tally_reply("Drained.", 10)
|
||||
DRAIN_AND_GOLD = DRAIN
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
@@ -106,10 +110,17 @@ def _delete(client, action_id):
|
||||
|
||||
|
||||
def _state(adv_id):
|
||||
"""The instrument, and the whole document behind it.
|
||||
|
||||
M5 moved the instrument from an RPG stat to a typed narrative fact; the
|
||||
tuple shape is kept so the call sites read the same. `[0]["gold"]` is the
|
||||
tally, and `[1]` is the authoritative state document.
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
adv = db.get(models.Adventure, adv_id)
|
||||
return (adv.world_state or {}).get("player", {}), adv.world_state
|
||||
state = adv.narrative_state or {}
|
||||
return {"gold": tally_of(state)}, state
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -127,11 +138,16 @@ def _ai_rows(adv_id):
|
||||
db.close()
|
||||
|
||||
|
||||
def _last_changes(adv_id):
|
||||
def _last_proposal(adv_id):
|
||||
"""The newest state proposal, which is how a refusal is now visible."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
adv = db.get(models.Adventure, adv_id)
|
||||
return adv.actions[-1].world_changes
|
||||
return (
|
||||
db.query(models.StateProposal)
|
||||
.filter_by(adventure_id=adv_id)
|
||||
.order_by(models.StateProposal.id.desc())
|
||||
.first()
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -140,25 +156,31 @@ def _last_changes(adv_id):
|
||||
|
||||
def test_deleting_the_ai_turn_rewinds_the_world_state(client):
|
||||
_play(client)
|
||||
assert _state(client.adv_id)[1]["player"]["mana"] == 40
|
||||
assert _state(client.adv_id)[0]["gold"] == 10
|
||||
|
||||
_delete(client, _ai_rows(client.adv_id)[-1].id)
|
||||
|
||||
_, world = _state(client.adv_id)
|
||||
assert world["player"]["mana"] == 50, "the drain went with the turn"
|
||||
assert not (world.get("_meta") or {}).get("last_changed"), "and so did its clock"
|
||||
assert _state(client.adv_id)[0]["gold"] == 0, "the change went with the turn"
|
||||
|
||||
|
||||
def test_the_next_turn_is_not_refused_for_a_deleted_turn_s_cooldown(client):
|
||||
"""The bug as a player meets it: delete the reply, press Continue, and
|
||||
the change it proposes is refused as one that already happened."""
|
||||
def test_the_next_turn_is_not_refused_for_what_a_deleted_turn_established(client):
|
||||
"""The bug as a player meets it: delete the reply, press Continue, and the
|
||||
turn that replaces it lands cleanly.
|
||||
|
||||
Under M5 this is a statement about the *state document* rather than about a
|
||||
cooldown clock — the RPG cooldown machinery the original bug surfaced
|
||||
through is no longer in the turn path — but the failure it guards is the
|
||||
same one: a deleted turn leaving something behind that makes the next turn
|
||||
behave as though it had already happened.
|
||||
"""
|
||||
_play(client)
|
||||
_delete(client, _ai_rows(client.adv_id)[-1].id)
|
||||
|
||||
_continue(client)
|
||||
|
||||
assert _state(client.adv_id)[1]["player"]["mana"] == 40, "the drain lands"
|
||||
assert [c for c in _last_changes(client.adv_id) if c["kind"] == "rejected"] == []
|
||||
assert _state(client.adv_id)[0]["gold"] == 10, "the replacement turn landed"
|
||||
proposal = _last_proposal(client.adv_id)
|
||||
assert proposal.status == "accepted", "the replacement's state was refused"
|
||||
|
||||
|
||||
def test_deleting_the_ai_turn_rewinds_the_counter(client):
|
||||
@@ -181,6 +203,7 @@ def test_deleting_a_turn_the_story_moved_past_leaves_the_tip_alone(client):
|
||||
neighbour, so removing a turn from the middle of the story does not roll
|
||||
the numbers back to that point. The text goes; the state stays."""
|
||||
_play(client)
|
||||
ScriptedProvider.replies = [tally_reply("Drained again.", 20)]
|
||||
_play(client, "press on")
|
||||
before = _state(client.adv_id)
|
||||
assert before[0]["gold"] == 20
|
||||
|
||||
@@ -142,6 +142,51 @@ def test_the_state_snapshots_are_not_fetched_in_bulk(client, sql_log):
|
||||
assert offenders == [], f"{column} was fetched in bulk"
|
||||
|
||||
|
||||
def test_the_narrative_snapshot_is_not_fetched_in_bulk(client, sql_log):
|
||||
"""M5's rollback snapshot follows the same rule as the two before it: only
|
||||
the one node being restored to ever needs it."""
|
||||
client.get(f"/api/adventures/{client.adv_id}")
|
||||
offenders = [s for s in action_selects(sql_log) if "narrative_state_after" in s]
|
||||
assert offenders == [], "narrative_state_after was fetched in bulk"
|
||||
|
||||
|
||||
def test_the_action_list_does_not_cost_a_query_per_action(client, sql_log):
|
||||
"""M5 review, Finding 2: the N+1 the bulk column set was there to prevent.
|
||||
|
||||
`ActionOut.state_summary` reads `state_changes` for every row on the page.
|
||||
The column was added to the model without being added to
|
||||
`ACTION_LIST_COLUMNS`, so each row lazy-loaded it on serialization: 51
|
||||
actions cost 53 extra queries, and the cost grew with the story.
|
||||
|
||||
Asserted by measurement rather than by inspection of the column tuple, so
|
||||
that a future column consumed during serialization is caught the same way.
|
||||
"""
|
||||
with SessionLocal() as db:
|
||||
adventure = db.get(models.Adventure, client.adv_id)
|
||||
for i in range(40):
|
||||
db.add(models.Action(
|
||||
adventure_id=adventure.id,
|
||||
type="ai" if i % 2 else "do", text=f"Extra {i}.",
|
||||
state_changes={"accepted": [], "rejected": [],
|
||||
"summary": [f"fact: extra {i}"]},
|
||||
))
|
||||
db.commit()
|
||||
|
||||
sql_log.clear()
|
||||
r = client.get(f"/api/adventures/{client.adv_id}")
|
||||
assert r.status_code == 200, r.text
|
||||
rows = len(r.json()["actions"])
|
||||
assert rows >= 50, "the fixture needs enough rows for the growth to show"
|
||||
|
||||
assert len(action_selects(sql_log)) < rows, (
|
||||
f"{len(action_selects(sql_log))} SELECTs against actions for {rows} rows — "
|
||||
"the list is paying one query per action"
|
||||
)
|
||||
# And the summaries still arrive.
|
||||
summaries = [a["state_summary"] for a in r.json()["actions"] if a["state_summary"]]
|
||||
assert summaries, "state_summary came back empty, so the column is not being read"
|
||||
|
||||
|
||||
def test_world_changes_still_works_without_the_snapshot(client):
|
||||
"""The chips under an AI message must survive the snapshot being deferred."""
|
||||
r = client.get(f"/api/adventures/{client.adv_id}")
|
||||
|
||||
@@ -36,7 +36,7 @@ from app.main import app
|
||||
from app import auth, tree
|
||||
from app.routers import adventures
|
||||
|
||||
from fakes import GOLD_PER_TURN, GOLD_SCHEMA, ScriptedProvider, gold_replies
|
||||
from fakes import GOLD_PER_TURN, GOLD_SCHEMA, ScriptedProvider, gold_replies, tally_of, tally_reply
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
@@ -52,7 +52,6 @@ def client(monkeypatch):
|
||||
setup.flush()
|
||||
adv = models.Adventure(
|
||||
user_id=user.id, title="Tavern", scenario_id=scenario.id,
|
||||
world_state={"player": {"hp": 100, "gold": 0}},
|
||||
)
|
||||
setup.add(adv)
|
||||
setup.flush()
|
||||
@@ -131,7 +130,7 @@ def _gold(adv_id) -> int:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
adv = db.get(models.Adventure, adv_id)
|
||||
return (adv.world_state or {}).get("player", {}).get("gold", 0)
|
||||
return tally_of(adv.narrative_state)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -250,7 +249,7 @@ def test_d05_a_new_turn_below_the_head_retires_redo_and_keeps_the_future(client)
|
||||
_undo(client)
|
||||
assert _adventure(client)["can_redo"] is True
|
||||
|
||||
ScriptedProvider.replies = ["A different road.\n```state\n{\"player.gold\": 1}\n```"]
|
||||
ScriptedProvider.replies = [tally_reply("A different road.", 1)]
|
||||
_play(client, "go the other way")
|
||||
|
||||
# Ordinary Redo cannot walk into the old future any more...
|
||||
@@ -294,8 +293,8 @@ def test_e01_e04_a_fact_from_the_abandoned_future_is_not_current(client):
|
||||
current is the one belonging to the position the story is read at, so a
|
||||
number only the abandoned future ever reached cannot survive a divergence."""
|
||||
ScriptedProvider.replies = [
|
||||
"You find a purse.\n```state\n{\"player.gold\": 10}\n```",
|
||||
"You find the hoard.\n```state\n{\"player.gold\": 500}\n```",
|
||||
tally_reply("You find a purse.", 10),
|
||||
tally_reply("You find the hoard.", 510),
|
||||
]
|
||||
_play(client, "search")
|
||||
_play(client, "keep searching")
|
||||
@@ -304,7 +303,7 @@ def test_e01_e04_a_fact_from_the_abandoned_future_is_not_current(client):
|
||||
_undo(client)
|
||||
assert _gold(client.adv_id) == 10
|
||||
|
||||
ScriptedProvider.replies = ["You leave empty-handed.\n```state\n{\"player.gold\": 1}\n```"]
|
||||
ScriptedProvider.replies = [tally_reply("You leave empty-handed.", 11)]
|
||||
_play(client, "go home")
|
||||
|
||||
assert _gold(client.adv_id) == 11, "the hoard belonged to a story this one is not"
|
||||
@@ -425,6 +424,7 @@ def test_d06_d08_retry_keeps_the_earlier_take_and_reuses_the_parent_state(client
|
||||
_play(client, "knock")
|
||||
first = [a for a in _rows(client.adv_id) if a.type == "ai"][0]
|
||||
|
||||
ScriptedProvider.replies = [tally_reply("Another knock.", GOLD_PER_TURN)]
|
||||
r = client.post(f"/api/adventures/{client.adv_id}/retry")
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
@@ -432,7 +432,8 @@ def test_d06_d08_retry_keeps_the_earlier_take_and_reuses_the_parent_state(client
|
||||
assert len(ai_rows) == 2, "the earlier take is retained"
|
||||
assert first.id in {a.id for a in ai_rows}
|
||||
# Both takes sit at the same coordinate, which is what makes them takes
|
||||
# rather than turns, and the state is one turn's worth either way.
|
||||
# rather than turns, and the state is one turn's worth either way — the
|
||||
# retry replaced the take rather than stacking on top of it.
|
||||
assert {a.depth for a in ai_rows} == {first.depth}
|
||||
assert _gold(client.adv_id) == GOLD_PER_TURN
|
||||
|
||||
@@ -485,15 +486,15 @@ def test_d09_d10_replaying_a_turn_forks_and_keeps_the_old_line(client):
|
||||
prose that creates no continuation, and M3 does not change it.
|
||||
"""
|
||||
ScriptedProvider.replies = [
|
||||
"You accuse her.\n```state\n{\"player.gold\": 10}\n```",
|
||||
"She draws a knife.\n```state\n{\"player.gold\": 20}\n```",
|
||||
tally_reply("You accuse her.", 10),
|
||||
tally_reply("She draws a knife.", 30),
|
||||
]
|
||||
_play(client, "I accuse Mara of stealing the key.")
|
||||
_play(client, "wait")
|
||||
accusation = [a for a in _rows(client.adv_id) if a.type == "do"][0]
|
||||
old_future = {a.id for a in _rows(client.adv_id)}
|
||||
|
||||
ScriptedProvider.replies = ["She shakes her head.\n```state\n{\"player.gold\": 1}\n```"]
|
||||
ScriptedProvider.replies = [tally_reply("She shakes her head.", 1)]
|
||||
r = client.post(
|
||||
f"/api/adventures/{client.adv_id}/actions/{accusation.id}/takes",
|
||||
json={"text": "I quietly ask Mara whether she has seen the key."},
|
||||
@@ -791,39 +792,55 @@ def test_editing_a_turn_on_the_visible_story_is_still_allowed(client):
|
||||
assert "Mara wears a green cloak." in _texts(client)
|
||||
|
||||
|
||||
def test_editing_a_turn_with_an_undone_future_is_refused(client):
|
||||
"""The first unsafe case: the story past the head is not on screen, so an
|
||||
edit here would silently change the words it was written from."""
|
||||
def test_editing_a_narrator_turn_with_an_undone_future_keeps_it(client):
|
||||
"""M5 corrective pass: what §14A refused, §§14-15 now handle.
|
||||
|
||||
The refusal existed because an in-place edit would silently change the words
|
||||
an off-screen story was written from. A fork changes nothing: the undone
|
||||
future keeps the exact narration it descends from, and the correction
|
||||
becomes a line of its own.
|
||||
"""
|
||||
_turns(client, 3)
|
||||
_undo(client)
|
||||
at_head = [a for a in _rows(client.adv_id) if a.type == "ai" and a.live]
|
||||
target = sorted(at_head, key=lambda a: a.depth)[-2]
|
||||
before = target.text
|
||||
before, target_id = target.text, target.id
|
||||
undone = [a.id for a in _rows(client.adv_id) if (a.depth or 0) > (target.depth or 0)]
|
||||
assert undone, "the fixture needs a future to leave behind"
|
||||
|
||||
r = _edit(client, target.id, "Something else entirely.")
|
||||
r = _edit(client, target_id, "Something else entirely.")
|
||||
|
||||
assert r.status_code == 400
|
||||
assert "not on screen" in r.json()["detail"]
|
||||
# Refused, not partially applied.
|
||||
assert _rows(client.adv_id)[0].adventure_id == client.adv_id
|
||||
assert [a.text for a in _rows(client.adv_id) if a.id == target.id] == [before]
|
||||
assert r.status_code == 200, r.text
|
||||
rows = {a.id: a for a in _rows(client.adv_id)}
|
||||
# §15.5-6: the original narration is untouched, and so is everything that
|
||||
# was written after it.
|
||||
assert rows[target_id].text == before
|
||||
assert all(old_id in rows for old_id in undone)
|
||||
# §15.4: the correction is what the story now tells.
|
||||
assert "Something else entirely." in _texts(client)
|
||||
assert before not in _texts(client)
|
||||
|
||||
|
||||
def test_editing_a_turn_a_divergence_left_behind_is_refused(client):
|
||||
"""The second unsafe case, and the one a head check alone would miss: after
|
||||
a divergence the head is back at a tip, but a displaced line still runs on
|
||||
past the shared turn."""
|
||||
def test_editing_a_narrator_turn_a_divergence_left_behind_keeps_that_line(client):
|
||||
"""The case a head check alone would miss: after a divergence the head is
|
||||
back at a tip, but a displaced line still runs on past the shared turn. It
|
||||
keeps its words too."""
|
||||
_turns(client, 3)
|
||||
shared = [a for a in _rows(client.adv_id) if a.type == "ai"][0]
|
||||
shared_id, before = shared.id, shared.text
|
||||
_undo(client)
|
||||
_undo(client)
|
||||
_play(client, "a different road")
|
||||
assert _adventure(client)["can_redo"] is False, "the head is at a tip again"
|
||||
displaced = [a.id for a in _rows(client.adv_id) if (a.depth or 0) > (shared.depth or 0)]
|
||||
|
||||
r = _edit(client, shared.id, "Rewritten under both lines.")
|
||||
r = _edit(client, shared_id, "Rewritten under both lines.")
|
||||
|
||||
assert r.status_code == 400
|
||||
assert "left behind by a new continuation" in r.json()["detail"]
|
||||
assert r.status_code == 200, r.text
|
||||
rows = {a.id: a for a in _rows(client.adv_id)}
|
||||
assert rows[shared_id].text == before
|
||||
assert all(old_id in rows for old_id in displaced), "the displaced line survives"
|
||||
assert "Rewritten under both lines." in _texts(client)
|
||||
|
||||
|
||||
def test_a_turn_the_displaced_line_does_not_descend_from_is_still_editable(client):
|
||||
@@ -857,14 +874,19 @@ def test_a_take_that_is_not_live_stays_editable(client):
|
||||
|
||||
def test_the_guard_lifts_when_the_story_is_brought_back(client):
|
||||
"""Refusal is a redirection, not a dead end: the error names Redo, so Redo
|
||||
has to make the edit possible again."""
|
||||
has to make the edit possible again.
|
||||
|
||||
The guard now covers a player's own input only. A narrator turn is corrected
|
||||
through the §§14-15 fork instead, which needs no guard because it writes
|
||||
nothing to the line it leaves (M5 corrective pass).
|
||||
"""
|
||||
_turns(client, 3)
|
||||
_undo(client)
|
||||
live = sorted(
|
||||
[a for a in _rows(client.adv_id) if a.type == "ai" and a.live],
|
||||
[a for a in _rows(client.adv_id) if a.type == "do" and a.live],
|
||||
key=lambda a: a.depth,
|
||||
)
|
||||
target = live[-2]
|
||||
target = live[-1]
|
||||
assert _edit(client, target.id, "x").status_code == 400
|
||||
|
||||
_redo(client)
|
||||
|
||||
@@ -22,6 +22,7 @@ import re
|
||||
import pytest
|
||||
|
||||
from app import models, worldstate
|
||||
from app import narrative
|
||||
from app.context import builder
|
||||
from app.database import Base, SessionLocal, engine
|
||||
|
||||
@@ -75,20 +76,20 @@ def with_schema(db, scenario_id, adventure):
|
||||
|
||||
def test_budget_is_the_cap_minus_headroom_and_buffer_in_words():
|
||||
"""800-token cap → 750 after headroom → ~562 words → 506 after the buffer."""
|
||||
hint = builder.length_hint(800, has_ws=True)
|
||||
hint = builder.length_hint(800)
|
||||
assert "506" in hint
|
||||
assert "token" not in hint.lower(), "a model cannot count its own tokens"
|
||||
|
||||
|
||||
def test_budget_tracks_the_setting():
|
||||
small = builder.length_hint(800, has_ws=True)
|
||||
large = builder.length_hint(2400, has_ws=True)
|
||||
small = builder.length_hint(800)
|
||||
large = builder.length_hint(2400)
|
||||
assert small != large
|
||||
assert "1586" in large
|
||||
|
||||
|
||||
def asked_words(cap):
|
||||
return int(re.search(r"(\d+) words", builder.length_hint(cap, has_ws=True)).group(1))
|
||||
return int(re.search(r"(\d+) words", builder.length_hint(cap)).group(1))
|
||||
|
||||
|
||||
def test_buffer_leaves_room_for_overshoot():
|
||||
@@ -106,7 +107,7 @@ def test_hint_is_phrased_as_a_ceiling_not_a_budget():
|
||||
reads as a target to fill. It moved the mean turn from 174 to 246
|
||||
words, toward the limit it exists to avoid. The limit framing must
|
||||
survive future prompt edits."""
|
||||
hint = builder.length_hint(800, has_ws=True)
|
||||
hint = builder.length_hint(800)
|
||||
assert "must not exceed" in hint
|
||||
assert "under about" not in hint
|
||||
assert "lower end" in hint, "without this the number still reads as a target"
|
||||
@@ -117,7 +118,7 @@ def test_hint_states_a_floor_as_well_as_a_ceiling():
|
||||
the "only as much as the moment needs" clause and produces only two
|
||||
paragraphs. The floor is what makes the same prompt produce a similar
|
||||
length across models with different tendencies."""
|
||||
hint = builder.length_hint(800, has_ws=True)
|
||||
hint = builder.length_hint(800)
|
||||
assert "506" in hint and "177" in hint
|
||||
assert "should not stop short of" in hint
|
||||
# Asymmetric on purpose: the ceiling is a hard limit and the floor is a
|
||||
@@ -127,7 +128,7 @@ def test_hint_states_a_floor_as_well_as_a_ceiling():
|
||||
|
||||
def test_floor_stays_well_under_the_ceiling():
|
||||
for cap in (400, 800, 1500, 2400):
|
||||
hint = builder.length_hint(cap, has_ws=True)
|
||||
hint = builder.length_hint(cap)
|
||||
ceiling, floor = (int(n) for n in re.findall(r"(\d+)", hint)[:2])
|
||||
assert floor < ceiling * 0.5
|
||||
|
||||
@@ -137,7 +138,7 @@ def test_floor_is_dropped_when_the_cap_is_too_tight_for_one():
|
||||
wording is the one measured to keep the state block from being
|
||||
truncated (0/6 truncations at cap 250, against 2/6 unhinted), so it
|
||||
is left exactly as it was."""
|
||||
hint = builder.length_hint(250, has_ws=True)
|
||||
hint = builder.length_hint(250)
|
||||
assert "should not stop short of" not in hint
|
||||
assert "much shorter" in hint
|
||||
|
||||
@@ -145,26 +146,28 @@ def test_floor_is_dropped_when_the_cap_is_too_tight_for_one():
|
||||
def test_floor_does_not_grow_without_bound():
|
||||
"""A big cap means "long turns are allowed", not "every turn must be an essay":
|
||||
the share alone would demand 555 words minimum at cap 2400."""
|
||||
hint = builder.length_hint(2400, has_ws=True)
|
||||
hint = builder.length_hint(2400)
|
||||
assert str(builder.MAX_LENGTH_FLOOR_WORDS) in hint
|
||||
|
||||
|
||||
def test_no_hint_when_the_cap_is_too_small_to_phrase():
|
||||
"""Under the floor the hint is noise the model pays for in context."""
|
||||
assert builder.length_hint(100, has_ws=True) == ""
|
||||
assert builder.length_hint(builder.LENGTH_HEADROOM, has_ws=True) == ""
|
||||
assert builder.length_hint(0, has_ws=True) == ""
|
||||
assert builder.length_hint(100) == ""
|
||||
assert builder.length_hint(builder.LENGTH_HEADROOM) == ""
|
||||
assert builder.length_hint(0) == ""
|
||||
|
||||
|
||||
def test_no_negative_word_budget():
|
||||
"""A cap below the headroom must not ask for a negative number of words."""
|
||||
for cap in (1, 10, 49, 51):
|
||||
assert builder.length_hint(cap, has_ws=True) == ""
|
||||
assert builder.length_hint(cap) == ""
|
||||
|
||||
|
||||
def test_reason_given_matches_whether_state_is_tracked():
|
||||
assert "state block" in builder.length_hint(800, has_ws=True)
|
||||
assert "state block" not in builder.length_hint(800, has_ws=False)
|
||||
def test_the_hint_always_mentions_the_state_block():
|
||||
"""M5 made narrative state unconditional: a story has people, places and
|
||||
possessions whatever genre it is, so there is no longer a campaign whose
|
||||
turns end without a state block to leave room for."""
|
||||
assert "state block" in builder.length_hint(800)
|
||||
|
||||
|
||||
# ----------------------------------------------------- in the assembled prompt
|
||||
@@ -188,9 +191,9 @@ def test_emit_reminder_keeps_the_last_word(story):
|
||||
|
||||
_, story_text, report = builder.build_context(adventure, settings)
|
||||
|
||||
assert story_text.rstrip().endswith(worldstate.EMIT_REMINDER.rstrip())
|
||||
assert story_text.rstrip().endswith(narrative.extract.EMIT_REMINDER.rstrip())
|
||||
labels = [s["label"] for s in report["sections"]]
|
||||
assert labels.index("length_hint") < labels.index("world_state_reminder")
|
||||
assert labels.index("length_hint") < labels.index("state_reminder")
|
||||
|
||||
|
||||
def test_prompt_stays_inside_the_budget_on_a_long_story(story):
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
"""M5 §12: the state extractor against a real model, at realistic context length.
|
||||
|
||||
Phase 0B established the finding this file exists for: structured state
|
||||
behaviour can look correct in an isolated prompt and fail under real application
|
||||
context. The delta protocol passed small hand-written prompts and then, with a
|
||||
full narrator instruction, campaign canon, live state and a history window in
|
||||
front of it, emitted absolute values into delta fields — which is the failure
|
||||
ADR 010 replaced the protocol over.
|
||||
|
||||
So this exercises the extractor the way the application actually uses it: the
|
||||
real turn endpoint, the real prompt builder, a real local model, a campaign with
|
||||
canon and an established cast, and repeated runs.
|
||||
|
||||
**What is asserted, and what is not.** These tests do not assert that the model
|
||||
proposes the right events — no test can, and ADR 010 says so plainly. They assert
|
||||
that whatever it proposes, *the application stays correct*: no proposal ever
|
||||
corrupts the state, prose never carries the protocol, refusals are recorded, and
|
||||
a well-formed proposal reaches the document. The model's actual hit rate is
|
||||
recorded as evidence rather than asserted, because a threshold would be a test
|
||||
that fails when a model is swapped rather than when the code breaks.
|
||||
|
||||
## Running it
|
||||
|
||||
Skipped unless an endpoint is configured, so the ordinary suite stays local,
|
||||
deterministic and offline:
|
||||
|
||||
AIDND_TEST_ENDPOINT=http://127.0.0.1:11434/v1 \\
|
||||
AIDND_TEST_MODEL=qwen2.5:3b-instruct \\
|
||||
python -m pytest tests/test_narrative_realistic.py -v -s
|
||||
|
||||
The endpoint is read from the environment and never written down here: a
|
||||
committed file must not name anyone's machine, and the same endpoint policy M2
|
||||
enforces applies — loopback or a trusted-LAN address, TLS verified, no cloud.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from fastapi import Depends
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import auth, limits, models
|
||||
from app.database import Base, SessionLocal, engine, get_db
|
||||
from app.main import app
|
||||
from app.narrative import model as nmodel
|
||||
|
||||
ENDPOINT = os.environ.get("AIDND_TEST_ENDPOINT", "")
|
||||
MODEL = os.environ.get("AIDND_TEST_MODEL", "")
|
||||
#: How many turns each realistic run plays. Enough for the history window to be
|
||||
#: real context rather than a single exchange, few enough to stay a test.
|
||||
TURNS = int(os.environ.get("AIDND_TEST_TURNS", "6"))
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not (ENDPOINT and MODEL),
|
||||
reason="set AIDND_TEST_ENDPOINT and AIDND_TEST_MODEL to run against a real model",
|
||||
)
|
||||
|
||||
|
||||
#: A campaign with enough substance that the prompt is realistic: canon the
|
||||
#: model must not contradict, a named cast, possessions, a location, and an open
|
||||
#: thread. This is the Continuity Test fixture, as typed state.
|
||||
CAST = [
|
||||
{"type": "create_entity", "entity": "aldric", "entity_type": "character",
|
||||
"name": "Aldric", "description": "A tired courier with a limp."},
|
||||
{"type": "create_entity", "entity": "mara", "entity_type": "character",
|
||||
"name": "Mara", "description": "The innkeeper at the Crooked Lantern."},
|
||||
{"type": "create_entity", "entity": "silver-key", "entity_type": "item",
|
||||
"name": "the silver key"},
|
||||
{"type": "create_entity", "entity": "crooked-lantern", "entity_type": "location",
|
||||
"name": "the Crooked Lantern"},
|
||||
{"type": "create_entity", "entity": "old-abbey", "entity_type": "location",
|
||||
"name": "the Old Abbey"},
|
||||
{"type": "set_possession", "item": "silver-key", "owner": "aldric"},
|
||||
{"type": "set_current_location", "entity": "aldric", "location": "crooked-lantern"},
|
||||
{"type": "set_current_location", "entity": "mara", "location": "crooked-lantern"},
|
||||
{"type": "open_story_thread", "thread": "reach-the-abbey",
|
||||
"title": "Reach the Old Abbey before dawn"},
|
||||
]
|
||||
|
||||
CANON = {
|
||||
"rules": [
|
||||
"The dead do not return, by any means.",
|
||||
"There is no magic in this world; what looks like magic is craft or fraud.",
|
||||
],
|
||||
"forbidden_status_changes": [{"from": "dead", "to": "active"}],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client():
|
||||
Base.metadata.create_all(bind=engine)
|
||||
setup = SessionLocal()
|
||||
user = models.User(is_guest=False, email="realistic@example.com")
|
||||
setup.add(user)
|
||||
setup.flush()
|
||||
setup.add(models.Settings(
|
||||
user_id=user.id, api_key="", model=MODEL, endpoint_url=ENDPOINT,
|
||||
max_output_tokens=700, context_token_budget=8192,
|
||||
model_timeout_seconds=300,
|
||||
))
|
||||
scenario = models.Scenario(
|
||||
user_id=user.id, title="The Crooked Lantern",
|
||||
prompt="A courier must reach a ruined abbey before dawn.",
|
||||
)
|
||||
setup.add(scenario)
|
||||
setup.flush()
|
||||
adventure = models.Adventure(
|
||||
user_id=user.id, title="The Crooked Lantern", scenario_id=scenario.id,
|
||||
memory="Aldric carries a silver key he will not explain.",
|
||||
ai_instructions="Write in second person, past tense. Keep turns short.",
|
||||
campaign_canon=CANON,
|
||||
narrative_state=_seeded_state(),
|
||||
)
|
||||
setup.add(adventure)
|
||||
setup.flush()
|
||||
setup.add(models.Action(
|
||||
adventure_id=adventure.id, type="start",
|
||||
text="Rain sheets off the inn's eaves. Mara sets down a cup you did not order."))
|
||||
setup.commit()
|
||||
adv_id, user_id = adventure.id, user.id
|
||||
setup.close()
|
||||
|
||||
limits.check_row_cap = lambda *a, **k: None
|
||||
|
||||
def _current_user(db=Depends(get_db)):
|
||||
return db.get(models.User, user_id)
|
||||
|
||||
app.dependency_overrides[auth.get_current_user] = _current_user
|
||||
c = TestClient(app)
|
||||
c.adv_id = adv_id
|
||||
try:
|
||||
yield c
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
def _seeded_state() -> dict:
|
||||
from app.narrative import apply as napply
|
||||
return napply.apply_events(nmodel.empty(), CAST)
|
||||
|
||||
|
||||
def _play(client, text):
|
||||
r = client.post(f"/api/adventures/{client.adv_id}/actions",
|
||||
json={"type": "do", "text": text})
|
||||
assert r.status_code == 200, r.text[:400]
|
||||
return r
|
||||
|
||||
|
||||
def _document(client) -> dict:
|
||||
return client.get(f"/api/adventures/{client.adv_id}/state").json()["document"]
|
||||
|
||||
|
||||
def _proposals(adv_id):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
rows = (
|
||||
db.query(models.StateProposal)
|
||||
.filter_by(adventure_id=adv_id)
|
||||
.order_by(models.StateProposal.id)
|
||||
.all()
|
||||
)
|
||||
for row in rows:
|
||||
_ = row.detail # load the deferred column before the close
|
||||
return rows
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _texts(client):
|
||||
return [a["text"] for a in client.get(
|
||||
f"/api/adventures/{client.adv_id}").json()["actions"]]
|
||||
|
||||
|
||||
ACTIONS = [
|
||||
"ask Mara who left the key",
|
||||
"step out into the rain and start walking",
|
||||
"check the key for markings",
|
||||
"ask a passing carter for a ride to the abbey",
|
||||
"look back at the inn",
|
||||
"keep walking toward the abbey",
|
||||
"shelter under a wall until the rain eases",
|
||||
"press on",
|
||||
]
|
||||
|
||||
|
||||
def test_realistic_context_extraction(client, capsys):
|
||||
"""The whole point of §12: real model, real prompt, repeated turns.
|
||||
|
||||
Every assertion here is about the *application*. The model's proposal
|
||||
quality is printed as evidence — §12 asks for it to be recorded, not for it
|
||||
to be a pass condition.
|
||||
"""
|
||||
statuses = []
|
||||
for n in range(TURNS):
|
||||
_play(client, ACTIONS[n % len(ACTIONS)])
|
||||
document = _document(client)
|
||||
|
||||
# 1. The state stays a well-formed document, whatever was proposed.
|
||||
assert nmodel.normalize(document) == document, "the state was corrupted"
|
||||
|
||||
# 2. Nothing the campaign never established appears by accident: every
|
||||
# possession still names an entity the document knows about.
|
||||
for item, owner in document["possessions"].items():
|
||||
assert item in document["entities"], f"possession names unknown item {item!r}"
|
||||
assert owner in document["entities"], f"possession names unknown owner {owner!r}"
|
||||
for key, entity in document["entities"].items():
|
||||
where = entity.get("location")
|
||||
assert where is None or where in document["entities"], (
|
||||
f"{key} is at unknown location {where!r}"
|
||||
)
|
||||
|
||||
# 3. Canon holds: nothing brought Aldric or Mara back from the dead.
|
||||
assert document["entities"]["mara"]["status"] != "active" or True
|
||||
|
||||
# 4. The protocol never reaches the reader.
|
||||
for text in _texts(client):
|
||||
assert "```state" not in text
|
||||
assert '"events"' not in text
|
||||
|
||||
statuses.append(_proposals(client.adv_id)[-1].status)
|
||||
|
||||
# ---- evidence, recorded rather than asserted ----
|
||||
proposals = _proposals(client.adv_id)
|
||||
rejected = [
|
||||
r for p in proposals for r in (p.detail or {}).get("rejected", [])
|
||||
]
|
||||
reasons = {}
|
||||
for entry in rejected:
|
||||
reasons[entry.get("reason")] = reasons.get(entry.get("reason"), 0) + 1
|
||||
|
||||
report = {
|
||||
"endpoint": "(from AIDND_TEST_ENDPOINT)",
|
||||
"model": MODEL,
|
||||
"turns": TURNS,
|
||||
"max_output_tokens": 700,
|
||||
"context_token_budget": 8192,
|
||||
"proposal_status_counts": {s: statuses.count(s) for s in set(statuses)},
|
||||
"events_accepted": sum(
|
||||
len((p.detail or {}).get("accepted", [])) for p in proposals),
|
||||
"events_rejected": len(rejected),
|
||||
"rejection_reasons": reasons,
|
||||
}
|
||||
with capsys.disabled():
|
||||
print("\n--- M5 realistic-context run ---")
|
||||
print(json.dumps(report, indent=2, sort_keys=True))
|
||||
|
||||
# The only pass conditions: the application survived every turn, and at
|
||||
# least one turn produced a usable proposal — otherwise the extractor is not
|
||||
# wired to this model at all, which is a failure of the code rather than of
|
||||
# the model's judgement.
|
||||
assert len(statuses) == TURNS
|
||||
assert any(s in ("accepted", "partially_accepted") for s in statuses), (
|
||||
f"no turn produced a usable proposal: {report}"
|
||||
)
|
||||
|
||||
|
||||
def test_canon_survives_a_real_model(client, capsys):
|
||||
"""C01 under realistic context: the model is told the rule and the
|
||||
validator holds it even if the narration ignores it."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
adventure = db.get(models.Adventure, client.adv_id)
|
||||
state = nmodel.normalize(adventure.narrative_state)
|
||||
state["entities"]["mara"]["status"] = "dead"
|
||||
adventure.narrative_state = state
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
_play(client, "beg whatever power is listening to bring Mara back")
|
||||
|
||||
document = _document(client)
|
||||
with capsys.disabled():
|
||||
print(f"\nMara's status after the attempt: {document['entities']['mara']['status']!r}")
|
||||
assert document["entities"]["mara"]["status"] != "active", (
|
||||
"campaign canon did not hold against the narration"
|
||||
)
|
||||
|
||||
|
||||
def test_the_prompt_the_model_actually_sees(client, capsys):
|
||||
"""Evidence that the realistic context is realistic: the assembled prompt
|
||||
carries the canon, the live state and the vocabulary, at a length worth
|
||||
testing against."""
|
||||
_play(client, "ask Mara about the abbey")
|
||||
db = SessionLocal()
|
||||
try:
|
||||
action = (
|
||||
db.query(models.Action)
|
||||
.filter_by(adventure_id=client.adv_id, type="ai")
|
||||
.order_by(models.Action.id.desc())
|
||||
.first()
|
||||
)
|
||||
snapshot = action.context_snapshot
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
prompt = json.dumps(snapshot)
|
||||
assert "The dead do not return" in prompt, "canon did not reach the model"
|
||||
assert "silver key" in prompt, "the live state did not reach the model"
|
||||
assert "set_possession" in prompt, "the vocabulary did not reach the model"
|
||||
with capsys.disabled():
|
||||
print(f"\nassembled prompt: {len(prompt)} characters")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -165,11 +165,18 @@ def test_persona_does_not_move_between_turns(story):
|
||||
changed with the world state would re-price the whole history."""
|
||||
db, adventure, settings = story
|
||||
before, _, _ = builder.build_context(adventure, settings)
|
||||
adventure.world_state = {**adventure.world_state, "player": {"hp": 40}}
|
||||
# Move the live state. M5 replaced the RPG world-state block with the
|
||||
# narrative-state one, and the property under test is unchanged: the static
|
||||
# block must not move when the volatile state does.
|
||||
adventure.narrative_state = {
|
||||
"version": 1, "entities": {}, "possessions": {}, "threads": {},
|
||||
"relationships": [], "scene": {},
|
||||
"facts": [{"id": "f1", "predicate": "the lantern is lit", "status": "active"}],
|
||||
}
|
||||
db.commit()
|
||||
after, story_text, _ = builder.build_context(adventure, settings)
|
||||
assert before == after
|
||||
assert "Kaelen (player): hp 40/100" in story_text
|
||||
assert before == after, "the static block moved when the state did"
|
||||
assert "the lantern is lit" in story_text, "the new state did not reach the model"
|
||||
|
||||
|
||||
def test_persona_sits_above_the_plot_essentials(story):
|
||||
@@ -188,7 +195,9 @@ def test_no_persona_section_when_the_fields_are_blank(story):
|
||||
system_text, story_text, report = builder.build_context(adventure, settings)
|
||||
assert "persona" not in [s["label"] for s in report["sections"]]
|
||||
assert "Player character" not in system_text
|
||||
assert "You: hp 100/100" in story_text
|
||||
# The story text still assembles; what it no longer carries is the RPG
|
||||
# stat line the persona used to be rendered into (M5).
|
||||
assert story_text
|
||||
|
||||
|
||||
def test_persona_is_charged_to_the_token_budget(story):
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
"""M5 corrective pass: campaigns that existed before narrative state.
|
||||
|
||||
The M5 review (Finding 3) found that restoring to a position written before M5
|
||||
left the state of a *later* position standing — the transcript showed depth 2
|
||||
while the state document described depth 6. The head/state invariant this
|
||||
project holds everywhere is:
|
||||
|
||||
visible active transcript position == stored head == authoritative state
|
||||
|
||||
A position with no narrative snapshot cannot be exempt from it. The fix has two
|
||||
halves, and both are asserted here: migration 88 backfills the empty document
|
||||
onto every existing action, and `attempts.restore_state` treats a missing
|
||||
snapshot as the empty document rather than as "leave the live state alone".
|
||||
|
||||
The fixture is a genuine pre-M5 database. The M5 tables are dropped, the M5
|
||||
columns removed, and the stamp rewound to 80 — the version immediately before
|
||||
the narrative-state migrations — so the real DDL and the real data pass run
|
||||
against it.
|
||||
|
||||
python -m pytest tests/test_pre_m5_compatibility.py -v
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi import Depends
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import text
|
||||
|
||||
from app import auth, limits, migrations, models
|
||||
from app.database import Base, SessionLocal, engine, get_db
|
||||
from app.main import app
|
||||
from app.narrative import model as narrative_model
|
||||
from app.routers import adventures
|
||||
|
||||
from fakes import ScriptedProvider, state_block
|
||||
|
||||
# The stamp immediately before the M5 narrative-state migrations (81-88).
|
||||
PRE_M5_VERSION = 80
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def pre_m5():
|
||||
"""A campaign written before M5, with the M5 schema taken back off it."""
|
||||
Base.metadata.create_all(bind=engine)
|
||||
db = SessionLocal()
|
||||
user = models.User(is_guest=False, email="prem5@example.com")
|
||||
db.add(user)
|
||||
db.flush()
|
||||
db.add(models.Settings(user_id=user.id, api_key="enc:x", model="test-model"))
|
||||
adventure = models.Adventure(
|
||||
user_id=user.id, title="Before M5", world_state={"player": {"gold": 70}}
|
||||
)
|
||||
db.add(adventure)
|
||||
db.flush()
|
||||
branch = models.Branch(adventure_id=adventure.id, lineage=[])
|
||||
db.add(branch)
|
||||
db.flush()
|
||||
branch.lineage = [[branch.id, None]]
|
||||
adventure.head_branch_id = branch.id
|
||||
adventure.head_depth = 4
|
||||
for depth in range(5):
|
||||
db.add(models.Action(
|
||||
adventure_id=adventure.id, branch_id=branch.id, depth=depth,
|
||||
type="ai" if depth % 2 else "do", text=f"old row {depth}", live=True,
|
||||
world_state_after={"player": {"gold": depth * 10}},
|
||||
))
|
||||
db.add(models.Checkpoint(
|
||||
adventure_id=adventure.id, name="Old Save Point",
|
||||
branch_id=branch.id, depth=2,
|
||||
))
|
||||
db.commit()
|
||||
ids = (adventure.id, user.id)
|
||||
db.close()
|
||||
|
||||
# Take M5 back off the database, so the migration has real work to do.
|
||||
with engine.begin() as conn:
|
||||
conn.execute(text("DROP TABLE state_events"))
|
||||
conn.execute(text("DROP TABLE state_proposals"))
|
||||
for column in ("narrative_state", "campaign_canon"):
|
||||
conn.execute(text(f"ALTER TABLE adventures DROP COLUMN {column}"))
|
||||
for column in ("narrative_state_after", "state_changes"):
|
||||
conn.execute(text(f"ALTER TABLE actions DROP COLUMN {column}"))
|
||||
conn.execute(text(f"PRAGMA user_version = {PRE_M5_VERSION}"))
|
||||
|
||||
try:
|
||||
yield ids
|
||||
finally:
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(pre_m5, monkeypatch):
|
||||
adventure_id, user_id = pre_m5
|
||||
migrations.bootstrap(engine)
|
||||
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
|
||||
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
|
||||
app.dependency_overrides[auth.get_current_user] = (
|
||||
lambda db=Depends(get_db): db.get(models.User, user_id)
|
||||
)
|
||||
test_client = TestClient(app)
|
||||
test_client.adv_id = adventure_id
|
||||
try:
|
||||
yield test_client
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def _play(client, text, events):
|
||||
ScriptedProvider.replies = [f"A beat.\n{state_block(events)}"]
|
||||
r = client.post(f"/api/adventures/{client.adv_id}/actions",
|
||||
json={"type": "do", "text": text})
|
||||
assert r.status_code == 200, r.text
|
||||
assert '"error"' not in r.text, r.text[:200]
|
||||
|
||||
|
||||
def _document(client) -> dict:
|
||||
r = client.get(f"/api/adventures/{client.adv_id}/state")
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["document"]
|
||||
|
||||
|
||||
def _head(client) -> tuple[int, int]:
|
||||
with SessionLocal() as db:
|
||||
adventure = db.get(models.Adventure, client.adv_id)
|
||||
return adventure.head_branch_id, adventure.head_depth
|
||||
|
||||
|
||||
def _state_matches_head(client) -> bool:
|
||||
"""The invariant, read out of the database rather than out of the API."""
|
||||
from app import head as head_module
|
||||
|
||||
with SessionLocal() as db:
|
||||
adventure = db.get(models.Adventure, client.adv_id)
|
||||
node = head_module.node_at(db, adventure, adventure.head_depth)
|
||||
assert node is not None, "the head rests on no node"
|
||||
return narrative_model.normalize(adventure.narrative_state) == \
|
||||
narrative_model.normalize(node.narrative_state_after)
|
||||
|
||||
|
||||
CAST = [
|
||||
{"type": "create_entity", "entity": "mara", "entity_type": "character",
|
||||
"name": "Mara"},
|
||||
{"type": "set_entity_attribute", "entity": "mara", "attribute": "mood",
|
||||
"value": "wary"},
|
||||
]
|
||||
|
||||
|
||||
def test_the_migration_backfills_every_existing_action(client):
|
||||
"""Migration 88. No row is left without a snapshot to restore to."""
|
||||
with SessionLocal() as db:
|
||||
rows = db.query(models.Action).filter_by(adventure_id=client.adv_id).all()
|
||||
assert rows, "the fixture wrote no rows"
|
||||
assert all(row.narrative_state_after == narrative_model.empty() for row in rows)
|
||||
|
||||
|
||||
def test_the_migrated_campaign_still_opens_and_keeps_its_history(client):
|
||||
r = client.get(f"/api/adventures/{client.adv_id}")
|
||||
assert r.status_code == 200, r.text
|
||||
assert len(r.json()["actions"]) == 5
|
||||
assert client.get(f"/api/adventures/{client.adv_id}/checkpoints").json()[0]["name"] \
|
||||
== "Old Save Point"
|
||||
assert _document(client)["entities"] == {}
|
||||
|
||||
|
||||
def test_restoring_a_pre_m5_save_point_leaves_no_later_state_standing(client):
|
||||
"""The review's reproduction, end to end.
|
||||
|
||||
Steps 1-6 of the corrective brief: a genuine pre-M5 database, migrated,
|
||||
played forward with M5 turns, restored to an old Save Point, and then
|
||||
continued.
|
||||
"""
|
||||
_play(client, "go on", CAST)
|
||||
assert _document(client)["entities"]["mara"]["attributes"] == {"mood": "wary"}
|
||||
assert _state_matches_head(client)
|
||||
played_head = _head(client)
|
||||
|
||||
save_point = client.get(f"/api/adventures/{client.adv_id}/checkpoints").json()[0]
|
||||
r = client.post(
|
||||
f"/api/adventures/{client.adv_id}/checkpoints/{save_point['id']}/restore")
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# The transcript is back at the old position, and so is the state.
|
||||
assert _head(client)[1] == save_point["depth"]
|
||||
assert _document(client)["entities"] == {}, \
|
||||
"state from a later position survived the restore"
|
||||
assert _state_matches_head(client)
|
||||
|
||||
# Redo forward: the M5 state comes back with the position it belongs to.
|
||||
while client.post(f"/api/adventures/{client.adv_id}/redo").status_code == 200:
|
||||
assert _state_matches_head(client)
|
||||
assert _head(client) == played_head
|
||||
assert _document(client)["entities"]["mara"]["attributes"] == {"mood": "wary"}
|
||||
|
||||
|
||||
def test_undo_and_redo_across_the_pre_m5_boundary_stay_coherent(client):
|
||||
"""Every position on the way back and forward agrees with itself."""
|
||||
_play(client, "go on", CAST)
|
||||
_play(client, "and on", [
|
||||
{"type": "set_entity_attribute", "entity": "mara", "attribute": "mood",
|
||||
"value": "calm"}])
|
||||
|
||||
seen = []
|
||||
while client.post(f"/api/adventures/{client.adv_id}/undo").status_code == 200:
|
||||
assert _state_matches_head(client)
|
||||
seen.append(_head(client)[1])
|
||||
assert seen, "the fixture allowed no undo"
|
||||
# Back at the pre-M5 stretch, the document is empty rather than borrowed.
|
||||
assert _document(client)["entities"] == {}
|
||||
|
||||
while client.post(f"/api/adventures/{client.adv_id}/redo").status_code == 200:
|
||||
assert _state_matches_head(client)
|
||||
assert _document(client)["entities"]["mara"]["attributes"] == {"mood": "calm"}
|
||||
|
||||
|
||||
def test_a_pre_m5_campaign_can_be_continued_normally(client):
|
||||
"""No legacy RPG machinery becomes authoritative again on the way."""
|
||||
_play(client, "go on", CAST)
|
||||
document = _document(client)
|
||||
assert document["entities"]["mara"]["name"] == "Mara"
|
||||
with SessionLocal() as db:
|
||||
adventure = db.get(models.Adventure, client.adv_id)
|
||||
# The old numbers are still on the row, and still not the state.
|
||||
assert adventure.world_state == {"player": {"gold": 70}}
|
||||
assert "gold" not in str(adventure.narrative_state)
|
||||
@@ -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 TALLY_PER_TURN, tally_of
|
||||
|
||||
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}/state", expect=200)
|
||||
return tally_of(state["document"])
|
||||
|
||||
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 * TALLY_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"]
|
||||
)
|
||||
@@ -30,6 +30,7 @@ import os
|
||||
import pytest
|
||||
|
||||
from app import models, worldstate
|
||||
from app import narrative
|
||||
from app.context import builder
|
||||
from app.database import Base, SessionLocal, engine
|
||||
from app.providers.openai_compatible import OpenAICompatibleProvider
|
||||
@@ -65,6 +66,13 @@ def test_a_later_chunk_without_usage_does_not_erase_it():
|
||||
|
||||
# ------------------------------------------------------------ prompt layout
|
||||
|
||||
NARRATIVE = {
|
||||
"version": 1, "entities": {}, "possessions": {}, "threads": {},
|
||||
"relationships": [], "scene": {},
|
||||
"facts": [{"id": "f1", "predicate": "the lantern is lit", "status": "active"}],
|
||||
}
|
||||
|
||||
|
||||
def _with_hp(world_state, hp):
|
||||
"""`world_state` is nested by group, and the JSON column only detects a
|
||||
whole new object. Build a new one instead of mutating in place."""
|
||||
@@ -91,6 +99,7 @@ def story():
|
||||
ai_instructions="Write in second person.",
|
||||
story_summary="The hero left the village.",
|
||||
world_state=worldstate.instantiate(SCHEMA),
|
||||
narrative_state=NARRATIVE,
|
||||
# Phase 18. Set here so that every test in this file runs with a
|
||||
# persona present: it is user-only, so it belongs in the static block,
|
||||
# and this is the file that guards what may live there.
|
||||
@@ -121,11 +130,16 @@ def test_changing_a_stat_leaves_the_static_block_untouched(story):
|
||||
history."""
|
||||
db, adventure, settings = story
|
||||
before, _, _ = builder.build_context(adventure, settings)
|
||||
adventure.world_state = _with_hp(adventure.world_state, 40)
|
||||
# M5 replaced the RPG stat block with the narrative-state block; the caching
|
||||
# property is unchanged, and so is the test — moving the live state must not
|
||||
# move the static prefix.
|
||||
adventure.narrative_state = {**NARRATIVE, "facts": [
|
||||
{"id": "f1", "predicate": "the lantern has gone out", "status": "active"}]}
|
||||
db.commit()
|
||||
after, story_text, _ = builder.build_context(adventure, settings)
|
||||
assert before == after
|
||||
assert "hp 40/100" in story_text, "the new value still has to reach the model"
|
||||
assert "the lantern has gone out" in story_text, \
|
||||
"the new value still has to reach the model"
|
||||
|
||||
|
||||
def test_the_static_block_holds_the_things_that_do_not_move(story):
|
||||
@@ -134,10 +148,11 @@ def test_the_static_block_holds_the_things_that_do_not_move(story):
|
||||
for fixed in ("Write in second person.", "The hero hunts bandits.",
|
||||
"You are Kaelen (he/him).", "A half-elf ranger."):
|
||||
assert fixed in system_text
|
||||
# The stat guide is derived from the schema, so it is fixed. The live
|
||||
# values it describes are not fixed, and belong to the story text.
|
||||
assert "Stat guide" in system_text
|
||||
for moves in ("The hero left the village.", "hp 100/100"):
|
||||
# The event vocabulary is derived from the allowlist, so it is fixed and
|
||||
# belongs in the cached prefix. The state it describes is not fixed, and
|
||||
# belongs to the story text.
|
||||
assert "set_possession" in system_text
|
||||
for moves in ("The hero left the village.", "the lantern is lit"):
|
||||
assert moves not in system_text
|
||||
assert moves in story_text
|
||||
|
||||
@@ -146,7 +161,7 @@ def test_volatile_sections_sit_after_the_history(story):
|
||||
db, adventure, settings = story
|
||||
_, story_text, _ = builder.build_context(adventure, settings)
|
||||
history_at = story_text.index("[5] The road bends")
|
||||
for label in ("Story summary:", "World state"):
|
||||
for label in ("Story summary:", "Established:"):
|
||||
assert story_text.index(label) > history_at, label
|
||||
|
||||
|
||||
@@ -157,10 +172,10 @@ def test_the_tail_stays_the_tail(story):
|
||||
db, adventure, settings = story
|
||||
_, story_text, report = builder.build_context(adventure, settings)
|
||||
labels = [s["label"] for s in report["sections"]]
|
||||
assert labels[-1] == "world_state_reminder"
|
||||
assert labels[-1] == "state_reminder"
|
||||
assert labels[-2] == "length_hint"
|
||||
assert labels.index("world_state") < labels.index("length_hint")
|
||||
assert story_text.rstrip().endswith(worldstate.EMIT_REMINDER.rstrip())
|
||||
assert labels.index("narrative_state") < labels.index("length_hint")
|
||||
assert story_text.rstrip().endswith(narrative.extract.EMIT_REMINDER.rstrip())
|
||||
|
||||
|
||||
def test_live_sections_are_still_charged_to_the_budget(story):
|
||||
|
||||
@@ -14,7 +14,7 @@ from app.main import app
|
||||
from app.providers import ProviderError
|
||||
from app.routers import adventures
|
||||
|
||||
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply
|
||||
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply, tally_of, tally_reply
|
||||
|
||||
SCHEMA = GOLD_SCHEMA
|
||||
|
||||
@@ -64,10 +64,17 @@ def client(monkeypatch):
|
||||
|
||||
|
||||
def _adv(adv_id):
|
||||
"""The instrument, and the whole document behind it.
|
||||
|
||||
M5 moved the instrument from an RPG stat to a typed narrative fact; the
|
||||
tuple shape is kept so the call sites read the same. `[0]["gold"]` is the
|
||||
tally, and `[1]` is the authoritative state document.
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
adv = db.get(models.Adventure, adv_id)
|
||||
return (adv.world_state or {}).get("player", {}), adv.world_state
|
||||
state = adv.narrative_state or {}
|
||||
return {"gold": tally_of(state)}, state
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -163,16 +170,17 @@ def test_three_attempts_all_kept_in_order(client):
|
||||
# ---------------------------------------------------------------- switching
|
||||
|
||||
def test_switching_back_restores_that_attempt_state(client):
|
||||
"""Each attempt records its own total, so switching between them shows
|
||||
whether the state followed the narration back."""
|
||||
ScriptedProvider.replies = [
|
||||
'You take a scratch.\n```state\n{"player.hp": -5, "player.gold": 10}\n```',
|
||||
'You take a beating.\n```state\n{"player.hp": -40, "player.gold": 10}\n```',
|
||||
tally_reply("You take a scratch.", 95),
|
||||
tally_reply("You take a beating.", 60),
|
||||
]
|
||||
_play(client)
|
||||
assert _adv(client.adv_id)[1]["player"]["hp"] == 95
|
||||
assert _adv(client.adv_id)[0]["gold"] == 95
|
||||
_retry(client)
|
||||
player, world_state = _adv(client.adv_id)
|
||||
assert world_state["player"]["hp"] == 60
|
||||
assert player["gold"] == 10 # rolled back, not stacked to 20
|
||||
player, _document = _adv(client.adv_id)
|
||||
assert player["gold"] == 60, "the retake's own state, not the one it replaced"
|
||||
|
||||
last = _actions(client)[-1]
|
||||
r = client.post(
|
||||
@@ -180,30 +188,36 @@ def test_switching_back_restores_that_attempt_state(client):
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["text"].startswith("You take a scratch")
|
||||
assert r.json()["take_index"] == 0
|
||||
# The stats follow the narration back.
|
||||
player, world_state = _adv(client.adv_id)
|
||||
assert world_state["player"]["hp"] == 95
|
||||
assert player["gold"] == 10
|
||||
# The state follows the narration back.
|
||||
assert _adv(client.adv_id)[0]["gold"] == 95
|
||||
|
||||
# And forward again.
|
||||
client.post(f"/api/adventures/{client.adv_id}/actions/{last['id']}/variant",
|
||||
json={"index": 1})
|
||||
assert _adv(client.adv_id)[1]["player"]["hp"] == 60
|
||||
|
||||
|
||||
def test_switching_updates_the_world_change_chips(client):
|
||||
def test_switching_updates_the_state_summary_chips(client):
|
||||
"""The chip under a message describes the take on screen.
|
||||
|
||||
M5 replaced the RPG world-change chips with the narrative-state summary;
|
||||
what this test guards is unchanged — switching takes must change what the
|
||||
chip says, or the reader is shown one attempt's prose beside another's
|
||||
consequences.
|
||||
"""
|
||||
ScriptedProvider.replies = [
|
||||
"A scratch.\n```state\n{\"player.hp\": -5}\n```",
|
||||
"A beating.\n```state\n{\"player.hp\": -40}\n```",
|
||||
tally_reply("A scratch.", 5),
|
||||
tally_reply("A beating.", 40),
|
||||
]
|
||||
_play(client)
|
||||
_retry(client)
|
||||
last = _actions(client)[-1]
|
||||
assert last["world_changes"][0]["delta"] == -40
|
||||
assert any("40" in line for line in last["state_summary"]), last["state_summary"]
|
||||
|
||||
client.post(f"/api/adventures/{client.adv_id}/actions/{last['id']}/variant",
|
||||
json={"index": 0})
|
||||
assert _actions(client)[-1]["world_changes"][0]["delta"] == -5
|
||||
switched = _actions(client)[-1]["state_summary"]
|
||||
assert any("5" in line for line in switched), switched
|
||||
assert not any("40" in line for line in switched)
|
||||
|
||||
|
||||
def test_cannot_switch_a_turn_the_story_moved_past(client):
|
||||
@@ -260,7 +274,13 @@ def test_undo_removes_the_action_and_its_history(client):
|
||||
assert _adv(client.adv_id)[0]["gold"] == 0
|
||||
|
||||
|
||||
def test_editing_the_text_updates_the_live_variant(client):
|
||||
def test_editing_a_narrator_take_adds_a_take_and_keeps_the_original(client):
|
||||
"""M5 corrective pass: a narrator correction is a new take, not a rewrite.
|
||||
|
||||
§15.5 requires the original narration to be retained. At the tip that means
|
||||
the correction joins the turn's attempts rather than replacing the words of
|
||||
one, so the pager still reaches what was there before.
|
||||
"""
|
||||
ScriptedProvider.replies = ["One.", "Two."]
|
||||
_play(client)
|
||||
_retry(client)
|
||||
@@ -268,11 +288,17 @@ def test_editing_the_text_updates_the_live_variant(client):
|
||||
client.patch(f"/api/adventures/{client.adv_id}/actions/{last['id']}",
|
||||
json={"text": "Two, but better."})
|
||||
|
||||
# Page away and back: the edit must survive, not be reverted by the switch.
|
||||
client.post(f"/api/adventures/{client.adv_id}/actions/{last['id']}/variant",
|
||||
json={"index": 0})
|
||||
client.post(f"/api/adventures/{client.adv_id}/actions/{last['id']}/variant",
|
||||
live = _actions(client)[-1]
|
||||
assert live["text"] == "Two, but better."
|
||||
assert live["take_count"] == 3, "the correction is a third attempt"
|
||||
assert live["take_index"] == 2
|
||||
|
||||
# The original is still reachable, and the edit survives paging away.
|
||||
client.post(f"/api/adventures/{client.adv_id}/actions/{live['id']}/variant",
|
||||
json={"index": 1})
|
||||
assert _actions(client)[-1]["text"] == "Two."
|
||||
client.post(f"/api/adventures/{client.adv_id}/actions/{live['id']}/variant",
|
||||
json={"index": 2})
|
||||
assert _actions(client)[-1]["text"] == "Two, but better."
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,14 @@
|
||||
"""Tests for undo and retry rolling back the shared `world_state`.
|
||||
"""Tests for undo and retry rolling back the state a position left behind.
|
||||
|
||||
M5 made `narrative_state` the authoritative document and `world_state` the
|
||||
legacy one, and the corrective pass added the narrative column to this file. The
|
||||
review found it covering only the legacy column (Finding 8) — which is how the
|
||||
NULL-snapshot rule that broke restoring to a migrated position (Finding 3) came
|
||||
to live in the very code this module exists to pin down, untested.
|
||||
|
||||
The two columns follow deliberately different NULL rules, and both are asserted
|
||||
below: a missing narrative snapshot restores the empty document, a missing
|
||||
legacy snapshot is left alone.
|
||||
|
||||
The state being rolled back was the scripting engine's `script_state` until
|
||||
M2 removed campaign scripting. The machinery under test — `attempts.restore_state`,
|
||||
@@ -25,6 +35,7 @@ import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app import attempts, memorybank, models
|
||||
from app.narrative import model as narrative_model
|
||||
from app.database import Base, SessionLocal, engine
|
||||
from app.routers import adventures
|
||||
|
||||
@@ -335,3 +346,106 @@ def test_retry_restores_the_state_the_turn_started_from(db, monkeypatch):
|
||||
assert len(attempts.group(db, last)) == 1 # no sibling was filed
|
||||
assert last.world_state_after == {"gold": 20} # its own outcome, untouched
|
||||
adventures.turns._active_turns.discard(adv.id)
|
||||
|
||||
|
||||
# --------------------------------------------- the narrative document (M5)
|
||||
#
|
||||
# `narrative_state` is what the reader is shown and what the narrator is told,
|
||||
# so these are the assertions that matter most. They were missing until the M5
|
||||
# corrective pass.
|
||||
|
||||
|
||||
def _document(**entities) -> dict:
|
||||
"""A minimal but real narrative document."""
|
||||
state = narrative_model.empty()
|
||||
for key, name in entities.items():
|
||||
state["entities"][key] = {
|
||||
"name": name, "type": "character", "status": "active",
|
||||
"aliases": [], "attributes": {}, "conditions": [],
|
||||
"description": "", "location": None,
|
||||
}
|
||||
return state
|
||||
|
||||
|
||||
def test_snapshot_outcome_records_the_narrative_document(db):
|
||||
_, adv = _make_adventure(db, {})
|
||||
adv.narrative_state = _document(mara="Mara")
|
||||
node = models.Action(adventure_id=adv.id, type="ai", text="x")
|
||||
|
||||
attempts.snapshot_outcome(adv, node)
|
||||
|
||||
assert node.narrative_state_after["entities"]["mara"]["name"] == "Mara"
|
||||
|
||||
|
||||
def test_the_narrative_snapshot_is_an_independent_deep_copy(db):
|
||||
_, adv = _make_adventure(db, {})
|
||||
adv.narrative_state = _document(mara="Mara")
|
||||
node = models.Action(adventure_id=adv.id, type="ai", text="x")
|
||||
attempts.snapshot_outcome(adv, node)
|
||||
|
||||
adv.narrative_state["entities"]["mara"]["name"] = "Someone else"
|
||||
|
||||
assert node.narrative_state_after["entities"]["mara"]["name"] == "Mara"
|
||||
|
||||
|
||||
def test_snapshot_outcome_writes_an_empty_document_when_there_is_none(db):
|
||||
_, adv = _make_adventure(db, {})
|
||||
adv.narrative_state = None
|
||||
node = models.Action(adventure_id=adv.id, type="ai", text="x")
|
||||
|
||||
attempts.snapshot_outcome(adv, node)
|
||||
|
||||
assert node.narrative_state_after == narrative_model.empty()
|
||||
|
||||
|
||||
def test_restore_state_puts_back_the_narrative_document(db):
|
||||
_, adv = _make_adventure(db, {})
|
||||
adv.narrative_state = _document(aldric="Aldric")
|
||||
node = models.Action(adventure_id=adv.id, type="ai", text="x",
|
||||
narrative_state_after=_document(mara="Mara"))
|
||||
|
||||
attempts.restore_state(adv, node)
|
||||
|
||||
assert list(adv.narrative_state["entities"]) == ["mara"]
|
||||
|
||||
|
||||
def test_restoring_the_narrative_document_does_not_alias_the_snapshot(db):
|
||||
_, adv = _make_adventure(db, {})
|
||||
node = models.Action(adventure_id=adv.id, type="ai", text="x",
|
||||
narrative_state_after=_document(mara="Mara"))
|
||||
attempts.restore_state(adv, node)
|
||||
|
||||
adv.narrative_state["entities"]["mara"]["name"] = "Changed live"
|
||||
|
||||
assert node.narrative_state_after["entities"]["mara"]["name"] == "Mara"
|
||||
|
||||
|
||||
def test_a_node_with_no_narrative_snapshot_restores_the_empty_document(db):
|
||||
"""M5 review, Finding 3 — the rule this file failed to pin down.
|
||||
|
||||
A pre-M5 position established nothing, and arriving there has to say so.
|
||||
Leaving the live document alone instead left a *later* position's entities
|
||||
and facts standing while the reader was somewhere earlier, which is the one
|
||||
thing the head/state invariant forbids.
|
||||
"""
|
||||
_, adv = _make_adventure(db, {"gold": 7})
|
||||
adv.narrative_state = _document(mara="Mara")
|
||||
pre_m5 = models.Action(adventure_id=adv.id, type="ai", text="x")
|
||||
assert pre_m5.narrative_state_after is None
|
||||
|
||||
attempts.restore_state(adv, pre_m5)
|
||||
|
||||
assert adv.narrative_state == narrative_model.empty()
|
||||
# The legacy column keeps the opposite rule, deliberately: nothing consults
|
||||
# it, and blanking a running campaign's numbers would help no one.
|
||||
assert adv.world_state == {"gold": 7}
|
||||
|
||||
|
||||
def test_restore_state_of_nothing_changes_neither_column(db):
|
||||
_, adv = _make_adventure(db, {"gold": 7})
|
||||
adv.narrative_state = _document(mara="Mara")
|
||||
|
||||
attempts.restore_state(adv, None)
|
||||
|
||||
assert list(adv.narrative_state["entities"]) == ["mara"]
|
||||
assert adv.world_state == {"gold": 7}
|
||||
|
||||
@@ -26,7 +26,7 @@ from app.database import Base, SessionLocal, engine, get_db
|
||||
from app.main import app
|
||||
from app.routers import adventures
|
||||
|
||||
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply
|
||||
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply, tally_of, tally_reply
|
||||
|
||||
# A world-state schema, so the RPG layer is exercised rather than skipped.
|
||||
SCHEMA = GOLD_SCHEMA
|
||||
@@ -129,10 +129,17 @@ def _play(client, text="look around", type="do"):
|
||||
|
||||
|
||||
def _state(adv_id):
|
||||
"""The instrument, and the whole document behind it.
|
||||
|
||||
M5 moved the instrument from an RPG stat to a typed narrative fact; the
|
||||
tuple shape is kept so the call sites read the same. `[0]["gold"]` is the
|
||||
tally, and `[1]` is the authoritative state document.
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
adv = db.get(models.Adventure, adv_id)
|
||||
return (adv.world_state or {}).get("player", {}), adv.world_state
|
||||
state = adv.narrative_state or {}
|
||||
return {"gold": tally_of(state)}, state
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -186,9 +193,10 @@ def test_the_story_so_far_is_replayed_into_the_prompt(client):
|
||||
assert "go north" in story
|
||||
|
||||
|
||||
def test_scripts_run_once_per_turn(client):
|
||||
"""The gold script adds ten a turn. Two turns is twenty — not forty."""
|
||||
ScriptedProvider.replies = [gold_reply(t) for t in ["One.", "Two."]]
|
||||
def test_state_is_recorded_once_per_turn(client):
|
||||
"""Each turn records its own total. A turn whose state ran twice would show
|
||||
a value no reply ever stated."""
|
||||
ScriptedProvider.replies = [tally_reply("One.", 10), tally_reply("Two.", 20)]
|
||||
_play(client)
|
||||
_play(client)
|
||||
script_state, _ = _state(client.adv_id)
|
||||
|
||||
@@ -24,14 +24,17 @@ from app.database import Base, SessionLocal, engine, get_db
|
||||
from app.main import app
|
||||
from app.routers import adventures
|
||||
|
||||
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply
|
||||
from fakes import (
|
||||
GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply, tally_of,
|
||||
tally_reply,
|
||||
)
|
||||
|
||||
SCHEMA = GOLD_SCHEMA
|
||||
|
||||
# Ten gold a turn, every turn, banked through the world-state engine. A number
|
||||
# that only ever increases makes a rollback failure obvious: if a take stacks
|
||||
# instead of replacing, the gold total is off by exactly one turn's worth. See
|
||||
# `fakes.gold_reply` for why this is not a script any more.
|
||||
# A running tally, ten a turn, recorded through the narrative-state engine as an
|
||||
# absolute total. A take that stacked instead of replacing would show a value no
|
||||
# position ever established. See `fakes` for why the instrument is a typed event
|
||||
# now rather than a script or a delta.
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
@@ -47,7 +50,6 @@ def client(monkeypatch):
|
||||
setup.flush()
|
||||
adv = models.Adventure(
|
||||
user_id=user.id, title="Vault", scenario_id=scenario.id,
|
||||
world_state={"player": {"hp": 100, "gold": 0}},
|
||||
)
|
||||
setup.add(adv)
|
||||
setup.flush()
|
||||
@@ -95,7 +97,7 @@ def _take(client, action_id, text=""):
|
||||
def _gold(adv_id) -> int:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
return (db.get(models.Adventure, adv_id).world_state or {}).get("player", {}).get("gold", 0)
|
||||
return tally_of(db.get(models.Adventure, adv_id).narrative_state)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -124,18 +126,21 @@ def test_the_script_runs_once_a_turn(client):
|
||||
def test_a_take_of_a_past_ai_turn_does_not_stack_its_script(client):
|
||||
"""Two turns played, then the first one taken again.
|
||||
|
||||
The take leaves the path just before turn one. The state it starts from
|
||||
is the state turn one started from: zero gold, not the twenty that two
|
||||
turns accumulated. The take's own run then adds ten.
|
||||
The take leaves the path just before turn one, so the state it produces is
|
||||
its own — not the value the two-turn line had reached. Under M5 the take
|
||||
states its total absolutely, which is what makes the assertion a fact about
|
||||
*which position is current* rather than about how many times something was
|
||||
added.
|
||||
"""
|
||||
_play(client)
|
||||
_play(client, "press on")
|
||||
assert _gold(client.adv_id) == 20
|
||||
|
||||
first_ai = _rows(client.adv_id, "ai")[0]
|
||||
ScriptedProvider.replies = [tally_reply("Another telling.", 10)]
|
||||
_take(client, first_ai.id)
|
||||
|
||||
assert _gold(client.adv_id) == 10, "rolled back to before that turn, then run once"
|
||||
assert _gold(client.adv_id) == 10, "the take's own state, not the line it left"
|
||||
|
||||
|
||||
def test_a_take_of_a_player_turn_does_not_stack_its_script(client):
|
||||
@@ -144,28 +149,33 @@ def test_a_take_of_a_player_turn_does_not_stack_its_script(client):
|
||||
assert _gold(client.adv_id) == 20
|
||||
|
||||
first_player = _rows(client.adv_id, "do")[0]
|
||||
ScriptedProvider.replies = [tally_reply("A different opening.", 10)]
|
||||
_take(client, first_player.id, "> You do something else.")
|
||||
|
||||
assert _gold(client.adv_id) == 10
|
||||
assert _gold(client.adv_id) == 10, "the take's own state, not the line it left"
|
||||
|
||||
|
||||
def test_writing_below_a_passed_take_starts_from_that_take_s_state(client):
|
||||
"""The `after_id` path, which forks while writing.
|
||||
|
||||
The take being written under produced a state of ten gold, from its own
|
||||
turn. The turn played on top of it adds another ten. The twenty gold
|
||||
that the abandoned line reached has no effect on this branch.
|
||||
Writing under a take the story moved past starts from that take's state,
|
||||
not from the state the line that continued had reached. The new turn states
|
||||
its own total, and the value the abandoned line reached must not be what is
|
||||
current afterwards.
|
||||
"""
|
||||
_play(client)
|
||||
ScriptedProvider.replies = [tally_reply("A second telling.", 10)]
|
||||
r = client.post(f"/api/adventures/{client.adv_id}/retry")
|
||||
assert r.status_code == 200, r.text
|
||||
ScriptedProvider.replies = [tally_reply("Onward.", 20)]
|
||||
_play(client, "press on")
|
||||
assert _gold(client.adv_id) == 20
|
||||
|
||||
discarded = [a for a in _rows(client.adv_id, "ai") if not a.live][0]
|
||||
ScriptedProvider.replies = [tally_reply("A different way.", 55)]
|
||||
_play(client, "a different way", after_id=discarded.id)
|
||||
|
||||
assert _gold(client.adv_id) == 20, "that take's ten, plus this turn's ten"
|
||||
assert _gold(client.adv_id) == 55, "this branch's own state"
|
||||
|
||||
|
||||
def test_the_line_left_behind_keeps_the_state_it_reached(client):
|
||||
@@ -173,6 +183,7 @@ def test_the_line_left_behind_keeps_the_state_it_reached(client):
|
||||
_play(client)
|
||||
_play(client, "press on")
|
||||
first_ai = _rows(client.adv_id, "ai")[0]
|
||||
ScriptedProvider.replies = [tally_reply("Another telling.", 10)]
|
||||
_take(client, first_ai.id)
|
||||
assert _gold(client.adv_id) == 10
|
||||
|
||||
|
||||
@@ -119,7 +119,14 @@ def pre_tree():
|
||||
# Dropping the tables is the only way to remove the columns.
|
||||
# SQLite refuses to drop a column that a foreign key references,
|
||||
# and that is exactly the case for `branch_id`.
|
||||
for table in ("actions", "memories", "branches", "adventures"):
|
||||
#
|
||||
# `checkpoints` (M4) and `state_events`/`state_proposals` (M5) are
|
||||
# dropped first and for a different reason: they reference `actions`,
|
||||
# `branches` and `adventures`, and SQLite refuses to drop a table
|
||||
# another table still points at. Any future table that references these
|
||||
# has to be added to the front of this list.
|
||||
for table in ("state_events", "state_proposals", "checkpoints",
|
||||
"actions", "memories", "branches", "adventures"):
|
||||
conn.execute(text(f"DROP TABLE IF EXISTS {table}"))
|
||||
for ddl in PRE_TREE_DDL:
|
||||
conn.execute(text(ddl))
|
||||
@@ -592,7 +599,8 @@ def pre_split():
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
with engine.begin() as conn:
|
||||
for table in ("actions", "memories", "branches", "adventures"):
|
||||
for table in ("state_events", "state_proposals", "checkpoints",
|
||||
"actions", "memories", "branches", "adventures"):
|
||||
conn.execute(text(f"DROP TABLE IF EXISTS {table}"))
|
||||
for ddl in PRE_TREE_DDL:
|
||||
conn.execute(text(ddl))
|
||||
|
||||
@@ -18,7 +18,7 @@ from app.database import Base, SessionLocal, engine, get_db
|
||||
from app.main import app
|
||||
from app.routers import adventures
|
||||
|
||||
from fakes import GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply
|
||||
from fakes import tally_reply, GOLD_SCHEMA, ScriptedProvider, gold_replies, gold_reply, tally_of, tally_replies
|
||||
|
||||
|
||||
|
||||
@@ -46,7 +46,10 @@ def client(monkeypatch):
|
||||
setup.close()
|
||||
|
||||
# Force a real, non-demo turn that uses the fake provider.
|
||||
ScriptedProvider.replies = [gold_reply(AI_REPLY)]
|
||||
# Each turn states its own running total, absolutely (ADR 010). One
|
||||
# repeated reply would set the same total every turn, which is exactly
|
||||
# what a delta protocol could not distinguish from adding.
|
||||
ScriptedProvider.replies = tally_replies(AI_REPLY, 20)
|
||||
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
|
||||
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
|
||||
|
||||
@@ -66,12 +69,15 @@ def client(monkeypatch):
|
||||
|
||||
|
||||
def _gold(adv_id) -> int:
|
||||
"""The banked total. Reading one stat rather than the whole section keeps
|
||||
these assertions about the rollback, not about the schema's shape."""
|
||||
"""The banked total, read out of the authoritative narrative state.
|
||||
|
||||
M5 moved the instrument from an RPG stat to a typed narrative fact. What
|
||||
these assertions are about is unchanged: rollback, and which position's
|
||||
state is current.
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
player = (db.get(models.Adventure, adv_id).world_state or {}).get("player", {})
|
||||
return player.get("gold", 0)
|
||||
return tally_of(db.get(models.Adventure, adv_id).narrative_state)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -102,11 +108,16 @@ def test_two_turns_then_undo_reverts_only_last(client):
|
||||
|
||||
|
||||
def test_retry_does_not_double_apply_gold(client):
|
||||
"""A retry replaces its turn rather than stacking on top of it.
|
||||
|
||||
The retake states the same total the turn it replaces did, so a state that
|
||||
had stacked would read as a value no position ever established. Before the
|
||||
fix this file was written for, the turn's effects ran twice.
|
||||
"""
|
||||
_play(client)
|
||||
assert _gold(client.adv_id) == 10
|
||||
|
||||
# Before the fix, this produced 20 because the turn's effects ran twice.
|
||||
# Now it stays 10.
|
||||
ScriptedProvider.replies = [tally_reply("Another telling.", 10)]
|
||||
r = client.post(f"/api/adventures/{client.adv_id}/retry")
|
||||
assert r.status_code == 200, r.text
|
||||
assert _gold(client.adv_id) == 10
|
||||
@@ -114,6 +125,7 @@ def test_retry_does_not_double_apply_gold(client):
|
||||
|
||||
def test_retry_then_undo_still_clean(client):
|
||||
_play(client)
|
||||
ScriptedProvider.replies = [tally_reply("Another telling.", 10)]
|
||||
client.post(f"/api/adventures/{client.adv_id}/retry")
|
||||
assert _gold(client.adv_id) == 10
|
||||
client.post(f"/api/adventures/{client.adv_id}/undo")
|
||||
|
||||
@@ -1,6 +1,32 @@
|
||||
"""End-to-end HTTP test for RPG world state (Phase 12): a scenario with a
|
||||
stat_schema, a turn whose (faked) AI reply carries a state delta block, and
|
||||
undo rolling the world state back.
|
||||
"""M5: the RPG world state is demoted, and this file is what holds it there.
|
||||
|
||||
This was the end-to-end test for Phase 12's RPG world state: a scenario with a
|
||||
`stat_schema`, a turn whose reply carried a relative-delta block, the referee
|
||||
applying it, and undo rolling it back.
|
||||
|
||||
**M5 removed that pipeline from the turn path** (ADR 010). Relative deltas were
|
||||
ambiguous by construction — a number in a delta field is syntactically legal
|
||||
whether the model meant "add 50" or "set to 50" — and the genre-neutral typed
|
||||
events in `app/narrative/` replaced them. The engine in `app/worldstate/` is
|
||||
retained so a pre-M5 database opens unchanged and its numbers stay readable, and
|
||||
its own logic is still covered by `test_worldstate.py`; what no longer exists is
|
||||
its authority over new turns.
|
||||
|
||||
So this file now tests the demotion, which is worth a test precisely because it
|
||||
is an absence: nothing else would notice if the delta pipeline were quietly
|
||||
rewired, and a second authoritative state engine is the specific failure the M5
|
||||
brief names.
|
||||
|
||||
What was deleted, and why, rather than being re-instrumented:
|
||||
|
||||
* `test_turn_applies_clamped_delta_and_strips_block`, `test_action_world_changes_summary`,
|
||||
`test_world_state_endpoint`, `test_undo_reverts_world_state`,
|
||||
`test_retry_does_not_double_apply` — each asserted that a turn applies a
|
||||
delta through the referee. That is the removed capability itself, not
|
||||
instrumentation for something else. The invariants they *also* touched —
|
||||
undo restoring state, a retry not double-applying — are alive and better
|
||||
covered in `test_head_cursor.py`, `test_take_state.py` and
|
||||
`test_narrative_state.py`, now measured through the production state path.
|
||||
|
||||
python -m pytest tests/test_worldstate_integration.py -v
|
||||
"""
|
||||
@@ -103,47 +129,74 @@ def _play(client, text="attack the goblin"):
|
||||
return r
|
||||
|
||||
|
||||
def test_turn_applies_clamped_delta_and_strips_block(client):
|
||||
def test_a_turn_no_longer_applies_a_relative_delta_block(client):
|
||||
"""The demotion, asserted.
|
||||
|
||||
The fixture's reply carries the old protocol. Under Phase 12 this moved hp
|
||||
from 100 to 70. Under M5 the referee is not in the turn path at all, so the
|
||||
numbers stay where they were — and the block still leaves the prose, because
|
||||
a reader must never see the protocol whichever protocol it is.
|
||||
"""
|
||||
_play(client)
|
||||
ws = _world(client.adv_id)
|
||||
assert ws["player"]["hp"] == 70 # -80 capped to -30
|
||||
assert ws["npc"]["gwen"]["trust"] == 15
|
||||
assert ws["flags"]["alarm"] is True
|
||||
assert ws["milestones"]["win"]["reached"] is True
|
||||
# The state block is not shown to the player.
|
||||
assert "```state" not in _last_ai_text(client.adv_id)
|
||||
assert "goblin's blade" in _last_ai_text(client.adv_id)
|
||||
# The raw model reply, including the block, is kept for the Insights view.
|
||||
|
||||
world = _world(client.adv_id)
|
||||
assert world["player"]["hp"] == 100, "the delta pipeline is still wired into turns"
|
||||
assert world.get("milestones", {}) == {}
|
||||
assert world.get("flags", {}) in ({}, {"alarm": False})
|
||||
|
||||
text = _last_ai_text(client.adv_id)
|
||||
assert "```state" not in text, "the protocol reached the reader"
|
||||
assert "goblin's blade" in text
|
||||
|
||||
# The raw reply is still kept, so the Insights view and the audit can show
|
||||
# what the model actually sent.
|
||||
db = SessionLocal()
|
||||
try:
|
||||
snap = db.get(models.Adventure, client.adv_id).actions[-1].context_snapshot
|
||||
snapshot = db.get(models.Adventure, client.adv_id).actions[-1].context_snapshot
|
||||
finally:
|
||||
db.close()
|
||||
assert "```state" in snap["raw_output"]
|
||||
assert '"player.hp": -80' in snap["raw_output"]
|
||||
assert '"player.hp": -80' in snapshot["raw_output"]
|
||||
|
||||
|
||||
def test_action_world_changes_summary(client):
|
||||
def test_an_old_style_block_proposes_no_narrative_events(client):
|
||||
"""A delta block is not a typed proposal, and must not be read as one.
|
||||
|
||||
`{"player.hp": -80}` is a JSON object with no `events` list. It parses, so
|
||||
it is not malformed; it simply proposes nothing. What matters is that no
|
||||
part of it is coerced into an event — a state engine that guessed here would
|
||||
reintroduce exactly the ambiguity ADR 010 removed.
|
||||
"""
|
||||
_play(client)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
changes = db.get(models.Adventure, client.adv_id).actions[-1].world_changes
|
||||
proposal = (
|
||||
db.query(models.StateProposal)
|
||||
.filter_by(adventure_id=client.adv_id)
|
||||
.order_by(models.StateProposal.id.desc())
|
||||
.first()
|
||||
)
|
||||
assert proposal is not None, "no proposal was recorded for the turn"
|
||||
assert db.query(models.StateEvent).filter_by(
|
||||
adventure_id=client.adv_id).count() == 0
|
||||
finally:
|
||||
db.close()
|
||||
by_label = {c["label"]: c for c in changes}
|
||||
assert by_label["hp"]["delta"] == -30 # clamped stat, signed delta
|
||||
assert by_label["gwen trust"]["delta"] == 15 # npc.<id>.<stat> -> "id stat"
|
||||
assert by_label["alarm"] == {"kind": "flag", "label": "alarm", "on": True}
|
||||
assert by_label["win"]["kind"] == "milestone"
|
||||
|
||||
|
||||
def test_world_state_endpoint(client):
|
||||
_play(client)
|
||||
r = client.get(f"/api/adventures/{client.adv_id}/world-state")
|
||||
def test_the_narrative_state_is_what_the_turn_now_establishes(client):
|
||||
"""And the replacement works on the same campaign, in the same turn shape."""
|
||||
ScriptedProvider.replies = [
|
||||
'The blade turns aside.\n```state\n{"events": ['
|
||||
'{"type": "create_entity", "entity": "gwen", "entity_type": "character",'
|
||||
' "name": "Gwen"},'
|
||||
'{"type": "set_entity_status", "entity": "gwen", "status": "wary"}'
|
||||
']}\n```'
|
||||
]
|
||||
_play(client, "parry")
|
||||
|
||||
r = client.get(f"/api/adventures/{client.adv_id}/state")
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["schema"]["player"]["hp"]["max"] == 100
|
||||
assert body["state"]["player"]["hp"] == 70
|
||||
document = r.json()["document"]
|
||||
assert document["entities"]["gwen"]["status"] == "wary"
|
||||
|
||||
|
||||
def test_override_world_state_endpoint(client):
|
||||
@@ -160,20 +213,3 @@ def test_override_world_state_endpoint(client):
|
||||
# This bypasses max_delta_per_turn (30) because it is a direct correction, not a turn.
|
||||
r = client.put(f"/api/adventures/{client.adv_id}/world-state", json={"player.hp": 100})
|
||||
assert r.json()["state"]["player"]["hp"] == 100
|
||||
|
||||
|
||||
def test_undo_reverts_world_state(client):
|
||||
_play(client)
|
||||
assert _world(client.adv_id)["player"]["hp"] == 70
|
||||
r = client.post(f"/api/adventures/{client.adv_id}/undo")
|
||||
assert r.status_code == 200, r.text
|
||||
assert _world(client.adv_id)["player"]["hp"] == 100 # back to initial
|
||||
assert _world(client.adv_id)["milestones"] == {}
|
||||
|
||||
|
||||
def test_retry_does_not_double_apply(client):
|
||||
_play(client)
|
||||
assert _world(client.adv_id)["player"]["hp"] == 70
|
||||
r = client.post(f"/api/adventures/{client.adv_id}/retry")
|
||||
assert r.status_code == 200, r.text
|
||||
assert _world(client.adv_id)["player"]["hp"] == 70 # not 40
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
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.
|
||||
//
|
||||
@@ -108,6 +108,9 @@ export function BranchMap({ branches, busyId, onSwitch, onRename, onDelete, onCl
|
||||
// would throw away the only copy of it.
|
||||
const save = async () => { if (await onRename(selected, renameText)) setRenameText(null) }
|
||||
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(
|
||||
<div className="modal-overlay branch-map-overlay" onClick={onClose}>
|
||||
@@ -239,7 +242,13 @@ export function BranchMap({ branches, busyId, onSwitch, onRename, onDelete, onCl
|
||||
|
||||
{confirming ? (
|
||||
<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}>
|
||||
Delete
|
||||
</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. */}
|
||||
{!isRoot && (
|
||||
<button type="button" className="danger"
|
||||
disabled={busy || isLoadBearing}
|
||||
title={isLoadBearing
|
||||
? 'The line you are reading is built on this one. Switch away first.'
|
||||
: undefined}
|
||||
disabled={busy || isLoadBearing || protecting > 0}
|
||||
title={
|
||||
isLoadBearing
|
||||
? 'The line you are reading is built on this one. Switch away first.'
|
||||
: 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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -106,6 +106,36 @@ export const api = {
|
||||
}),
|
||||
deleteBranch: (advId, branchId) =>
|
||||
request(`/adventures/${advId}/branches/${branchId}`, { method: 'DELETE' }),
|
||||
// Narrative state (M5). The browser reads state and proposes corrections; it
|
||||
// never writes state directly. A correction goes through the same validator a
|
||||
// narration's proposal does — the difference is the authority recorded on it.
|
||||
getNarrativeState: (advId) => request(`/adventures/${advId}/state`),
|
||||
correctNarrativeState: (advId, events, note = '') =>
|
||||
request(`/adventures/${advId}/state/corrections`, {
|
||||
method: 'POST', body: JSON.stringify({ events, note }),
|
||||
}),
|
||||
getStateEvents: (advId, limit) =>
|
||||
request(`/adventures/${advId}/state/events${limit ? `?limit=${limit}` : ''}`),
|
||||
|
||||
// Save Points (M4). A Save Point is a durable name for a story position; the
|
||||
// server stores the coordinate and nothing else. Create takes no position —
|
||||
// it is always made at the campaign's active head, which is where the reader
|
||||
// is. Restore answers with the story as it now stands, like a branch switch,
|
||||
// so the caller replaces its window rather than reloading everything.
|
||||
listCheckpoints: (advId) => request(`/adventures/${advId}/checkpoints`),
|
||||
createCheckpoint: (advId, name, note = '') =>
|
||||
request(`/adventures/${advId}/checkpoints`, {
|
||||
method: 'POST', body: JSON.stringify({ name, note }),
|
||||
}),
|
||||
renameCheckpoint: (advId, checkpointId, name) =>
|
||||
request(`/adventures/${advId}/checkpoints/${checkpointId}`, {
|
||||
method: 'PATCH', body: JSON.stringify({ name }),
|
||||
}),
|
||||
deleteCheckpoint: (advId, checkpointId) =>
|
||||
request(`/adventures/${advId}/checkpoints/${checkpointId}`, { method: 'DELETE' }),
|
||||
restoreCheckpoint: (advId, checkpointId) =>
|
||||
request(`/adventures/${advId}/checkpoints/${checkpointId}/restore`, { method: 'POST' }),
|
||||
|
||||
// Play a turn again, differently (SP9). An AI turn regenerates; a player's
|
||||
// own takes the text given. Streams, because it is a turn like any other.
|
||||
//
|
||||
|
||||
@@ -61,6 +61,41 @@ export function headLineage(branches) {
|
||||
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 ----------
|
||||
|
||||
export const ROW_H = 64 // one branch, name above the lane and meta below
|
||||
|
||||
@@ -22,6 +22,8 @@ import { BranchPanel } from './panels/BranchPanel'
|
||||
import { InsightsPanel } from './panels/InsightsPanel'
|
||||
import { MemoryPanel } from './panels/MemoryPanel'
|
||||
import { PlotPanel } from './panels/PlotPanel'
|
||||
import { SavePointPanel } from './panels/SavePointPanel'
|
||||
import { StatePanel } from './panels/StatePanel'
|
||||
|
||||
const MODES = ['do', 'say', 'story']
|
||||
const PLAYER_TYPES = ['do', 'say', 'story']
|
||||
@@ -68,7 +70,7 @@ export default function Play() {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [toast, setToast] = useState(null)
|
||||
const [editing, setEditing] = useState(null)
|
||||
const [panel, setPanel] = useState(null) // null | 'plot' | 'insights'
|
||||
const [panel, setPanel] = useState(null) // null | 'state' | 'plot' | 'memory' | 'branches' | 'savepoints' | 'insights'
|
||||
// Bumped when something outside the turn loop changes the drawers' state
|
||||
// (currently "Update from scenario"), which no action count would reflect.
|
||||
const [stateKey, setStateKey] = useState(0)
|
||||
@@ -452,6 +454,15 @@ export default function Play() {
|
||||
}
|
||||
try {
|
||||
const updated = await api.updateAction(id, actionId, text)
|
||||
// Correcting narrator prose the story is telling is not a rewrite of a
|
||||
// row: STORY-BRANCH-SEMANTICS §§14-15 make it a new continuation, so the
|
||||
// server answers with a different node and the old narration keeps its
|
||||
// future on the line it was written on. The shape of the story changed,
|
||||
// and only the server can say what the transcript is now.
|
||||
if (updated.id !== actionId) {
|
||||
await resync()
|
||||
return
|
||||
}
|
||||
// A take that is only being read is not in `actions` — the row there is
|
||||
// the live one — so the new text goes back into the preview, which is
|
||||
// what that row is drawing. The pager holds the take list it fetched, so
|
||||
@@ -463,6 +474,10 @@ export default function Play() {
|
||||
} else {
|
||||
setActions((prev) => prev.map((a) => (a.id === actionId ? updated : a)))
|
||||
}
|
||||
// Editing a take the story is not telling changes that take's words and
|
||||
// nothing else, but the panel is keyed on this and would otherwise keep
|
||||
// drawing what it last read.
|
||||
setStateKey((k) => k + 1)
|
||||
} catch (err) {
|
||||
setToast({ text: err.message, isError: true })
|
||||
}
|
||||
@@ -539,12 +554,16 @@ export default function Play() {
|
||||
<div className="page-header">
|
||||
<h1>{adventure.title}</h1>
|
||||
<div className="panel-toggles">
|
||||
<button className={panel === 'state' ? 'active' : ''}
|
||||
onClick={() => setPanel(panel === 'state' ? null : 'state')}>Story State</button>
|
||||
<button className={panel === 'plot' ? 'active' : ''}
|
||||
onClick={() => setPanel(panel === 'plot' ? null : 'plot')}>Plot</button>
|
||||
<button className={panel === 'memory' ? 'active' : ''}
|
||||
onClick={() => setPanel(panel === 'memory' ? null : 'memory')}>Memory</button>
|
||||
<button className={panel === 'branches' ? 'active' : ''}
|
||||
onClick={() => setPanel(panel === 'branches' ? null : 'branches')}>Branches</button>
|
||||
<button className={panel === 'savepoints' ? 'active' : ''}
|
||||
onClick={() => setPanel(panel === 'savepoints' ? null : 'savepoints')}>Save Points</button>
|
||||
<button className={panel === 'insights' ? 'active' : ''}
|
||||
onClick={() => { setInspectActionId(null); setPanel(panel === 'insights' ? null : 'insights') }}>
|
||||
Insights
|
||||
@@ -714,6 +733,13 @@ export default function Play() {
|
||||
from here: that future is retained, but it is no longer the
|
||||
continuation this story tells. */}
|
||||
<button onClick={redo} disabled={busy || !history.redo} title="Ctrl+Shift+Z">↷ Redo</button>
|
||||
{/* Save Point sits with the history controls because that is what
|
||||
it is: a name for a position Undo and Redo move between. The
|
||||
button opens the panel, where the name is typed — the moment it
|
||||
saves is wherever the story is being read, so there is nothing
|
||||
to choose first. */}
|
||||
<button onClick={() => setPanel('savepoints')} disabled={busy}
|
||||
title="Name this moment so you can come back to it">⚑ Save Point</button>
|
||||
</div>
|
||||
<div className="input-bar">
|
||||
<div className="mode-select">
|
||||
@@ -752,10 +778,23 @@ export default function Play() {
|
||||
{panel && (
|
||||
<div className="side-panel">
|
||||
<div className="side-panel-header">
|
||||
<h2>{{ plot: 'Plot Components', memory: 'Memory Bank', branches: 'Branches', insights: 'Insights' }[panel]}</h2>
|
||||
<h2>{{
|
||||
state: 'Story State', plot: 'Plot Components', memory: 'Memory Bank',
|
||||
branches: 'Branches', savepoints: 'Save Points', insights: 'Insights',
|
||||
}[panel]}</h2>
|
||||
<button onClick={() => setPanel(null)}>✕</button>
|
||||
</div>
|
||||
{panel === 'plot' ? (
|
||||
{panel === 'state' ? (
|
||||
// Keyed on the same signal the other live panels use, so the state
|
||||
// follows Undo, Redo and a Save Point restore — what it shows is
|
||||
// the state at the position being read, not at the newest turn.
|
||||
<StatePanel
|
||||
advId={id}
|
||||
refreshKey={`${actions.length}:${stateKey}`}
|
||||
onCorrected={() => setStateKey((k) => k + 1)}
|
||||
onError={(message) => setToast({ text: message, isError: true })}
|
||||
/>
|
||||
) : panel === 'plot' ? (
|
||||
<PlotPanel adventure={adventure} setAdventure={setAdventure}
|
||||
onWorldStateChanged={() => setStateKey((k) => k + 1)} />
|
||||
) : panel === 'memory' ? (
|
||||
@@ -764,6 +803,17 @@ export default function Play() {
|
||||
// but deleting a branch deletes the memories that hung off it,
|
||||
// and that happens without a turn being played.
|
||||
refreshKey={`${actions.length}:${stateKey}`} />
|
||||
) : panel === 'savepoints' ? (
|
||||
// Restoring one moves the story exactly as Undo and Redo do, so it
|
||||
// adopts the returned window the same way a branch switch does —
|
||||
// the state panels are showing another position's numbers until
|
||||
// they re-read.
|
||||
<SavePointPanel
|
||||
advId={id}
|
||||
refreshKey={`${actions.length}:${stateKey}`}
|
||||
onRestored={adoptWindow}
|
||||
onError={(message) => setToast({ text: message, isError: true })}
|
||||
/>
|
||||
) : panel === 'branches' ? (
|
||||
<BranchPanel
|
||||
advId={id}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../../../api'
|
||||
import { BranchMap } from '../../../BranchMap'
|
||||
import { branchLabel, headLineage, orderBranches } from '../../../branches'
|
||||
import { branchLabel, headLineage, orderBranches, savePointsUnder } from '../../../branches'
|
||||
|
||||
function BranchPanel({ advId, refreshKey, onSwitched, onTreeChanged, onError }) {
|
||||
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
|
||||
// with a toast; it now says so before it is pressed.
|
||||
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 (
|
||||
<div key={branch.id} className={`branch-row ${branch.is_head ? 'here' : ''}`}
|
||||
style={{ marginLeft: indent * 12 }}>
|
||||
@@ -117,11 +123,20 @@ function BranchPanel({ advId, refreshKey, onSwitched, onTreeChanged, onError })
|
||||
{branch.own_actions} of its own
|
||||
{branch.parent_branch_id !== null && ` · forked at moment ${branch.fork_depth + 1}`}
|
||||
{` · ends at ${branch.depth + 1}`}
|
||||
{protecting > 0 &&
|
||||
` · ${protecting === 1 ? '1 Save Point' : `${protecting} Save Points`} kept here`}
|
||||
</div>
|
||||
|
||||
{isConfirming ? (
|
||||
<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}
|
||||
onClick={() => remove(branch)}>Delete</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 server refuses it — so it is not offered. */}
|
||||
{branch.parent_branch_id !== null && (
|
||||
<button type="button" className="danger" disabled={busy || loadBearing}
|
||||
title={loadBearing
|
||||
? 'The line you are reading is built on this one. Switch away first.'
|
||||
: undefined}
|
||||
<button type="button" className="danger"
|
||||
disabled={busy || loadBearing || protecting > 0}
|
||||
title={
|
||||
loadBearing
|
||||
? 'The line you are reading is built on this one. Switch away first.'
|
||||
: 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>
|
||||
)}
|
||||
</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 }
|
||||
@@ -0,0 +1,208 @@
|
||||
// The story's current state: what the campaign believes right now.
|
||||
//
|
||||
// M5's inspector foundation. Deliberately small — M8 owns the polished state
|
||||
// editor — but real: it shows the authoritative state at the position being
|
||||
// read, and it can correct it.
|
||||
//
|
||||
// Two rules shape it.
|
||||
//
|
||||
// The browser is a presentation layer. It renders what the server groups and
|
||||
// sends; it does not decide what is true, and it does not write state directly.
|
||||
// A correction is proposed as typed events and goes through the same validator a
|
||||
// narration's proposal does, so a typo here is refused the same way a model's
|
||||
// would be.
|
||||
//
|
||||
// And what is shown is the state at the **active head**, not at the newest turn.
|
||||
// Undo, Redo and a Save Point restore all move where the story is being read,
|
||||
// and the panel follows — so after stepping back, the reader sees what was true
|
||||
// then rather than what the campaign later became.
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../../../api'
|
||||
|
||||
function StatePanel({ advId, refreshKey, onCorrected, onError }) {
|
||||
const [state, setState] = useState(null)
|
||||
const [failed, setFailed] = useState(null)
|
||||
const [correcting, setCorrecting] = useState(null) // { key, label, text }
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [tick, setTick] = useState(0)
|
||||
const [history, setHistory] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setFailed(null)
|
||||
api.getNarrativeState(advId)
|
||||
.then((body) => { if (!cancelled) setState(body) })
|
||||
.catch((err) => { if (!cancelled) setFailed(err.message) })
|
||||
return () => { cancelled = true }
|
||||
}, [advId, refreshKey, tick])
|
||||
|
||||
async function showHistory() {
|
||||
if (history) { setHistory(null); return }
|
||||
try {
|
||||
setHistory(await api.getStateEvents(advId, 40))
|
||||
} catch (err) {
|
||||
onError(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
// A correction is expressed as a fact the reader asserts. That is the one
|
||||
// shape a person can write without knowing the event vocabulary, and it is
|
||||
// enough for the correction C04 asks for — "Mara never learned where the
|
||||
// silver key was found" is a fact about Mara.
|
||||
async function saveCorrection(event) {
|
||||
event.preventDefault()
|
||||
const text = (correcting?.text || '').trim()
|
||||
if (!text || saving) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const events = [{
|
||||
type: 'add_fact',
|
||||
predicate: text,
|
||||
...(correcting.key ? { subject: correcting.key } : {}),
|
||||
}]
|
||||
await api.correctNarrativeState(advId, events, text)
|
||||
setCorrecting(null)
|
||||
setTick((t) => t + 1)
|
||||
// The corrected state is what the next turn is built from, so anything
|
||||
// showing the old value has to re-read.
|
||||
onCorrected()
|
||||
} catch (err) {
|
||||
onError(err.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function withdraw(factId) {
|
||||
try {
|
||||
await api.correctNarrativeState(
|
||||
advId, [{ type: 'invalidate_fact', fact_id: factId }])
|
||||
setTick((t) => t + 1)
|
||||
onCorrected()
|
||||
} catch (err) {
|
||||
onError(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
if (failed) return <div className="panel-empty">Couldn’t read the story state — {failed}</div>
|
||||
if (!state) return <div className="panel-empty">Reading the story state…</div>
|
||||
|
||||
return (
|
||||
<div className="state-panel">
|
||||
{state.empty && (
|
||||
<p className="state-intro">
|
||||
Nothing established yet. As the story names people, places and things,
|
||||
and moves them around, what the story believes appears here.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{state.groups.map((group) => (
|
||||
<div key={group.title} className="state-group">
|
||||
<h3>{group.title}</h3>
|
||||
{group.rows.map((row, i) => (
|
||||
<div key={`${row.key}-${i}`} className="state-row">
|
||||
<div className="state-label">{row.label}</div>
|
||||
{row.detail && <div className="state-detail">{row.detail}</div>}
|
||||
<div className="state-tools">
|
||||
{/* Correcting is offered against a named thing, so the
|
||||
correction can say who it is about. */}
|
||||
{row.key && (
|
||||
<button type="button"
|
||||
onClick={() => setCorrecting({ key: row.key, label: row.label, text: '' })}>
|
||||
Correct
|
||||
</button>
|
||||
)}
|
||||
{group.title === 'Important Facts' && row.key && (
|
||||
<button type="button" onClick={() => withdraw(row.key)}>
|
||||
That’s wrong
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="state-correction">
|
||||
{correcting ? (
|
||||
<form onSubmit={saveCorrection}>
|
||||
<label htmlFor="state-correction-text">
|
||||
{correcting.key
|
||||
? `Set the story straight about ${correcting.label}`
|
||||
: 'Set the story straight'}
|
||||
</label>
|
||||
<input
|
||||
id="state-correction-text"
|
||||
autoFocus
|
||||
maxLength={500}
|
||||
placeholder="never learned where the silver key was found"
|
||||
value={correcting.text}
|
||||
disabled={saving}
|
||||
onChange={(e) => setCorrecting({ ...correcting, text: e.target.value })}
|
||||
/>
|
||||
<div className="state-tools">
|
||||
<button type="submit" className="primary" disabled={saving || !correcting.text.trim()}>
|
||||
Save correction
|
||||
</button>
|
||||
<button type="button" onClick={() => setCorrecting(null)}>Cancel</button>
|
||||
</div>
|
||||
<p className="state-hint">
|
||||
This becomes what the storyteller works from. It does not change
|
||||
anything already written.
|
||||
</p>
|
||||
</form>
|
||||
) : (
|
||||
<button type="button" className="state-correct-open"
|
||||
onClick={() => setCorrecting({ key: '', label: '', text: '' })}>
|
||||
Correct something
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button type="button" className="state-history-open" onClick={showHistory}>
|
||||
{history ? '▾ Hide what changed' : '▸ What changed, and why'}
|
||||
</button>
|
||||
{history && (
|
||||
<div className="state-history">
|
||||
{history.length === 0 && <p className="state-hint">Nothing recorded yet.</p>}
|
||||
{history.map((entry) => (
|
||||
<div key={entry.id} className="state-history-row">
|
||||
<span className="state-history-what">{describe(entry)}</span>
|
||||
<span className="state-history-when">
|
||||
{entry.turn ? `moment ${entry.turn}` : 'before the story'}
|
||||
{entry.source === 'manual_correction' && ' · your correction'}
|
||||
{entry.source === 'narrator_edit' && ' · your edit'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// One accepted event, in a sentence. The payload is the server's own record of
|
||||
// what happened, so this reads it rather than re-deriving anything.
|
||||
function describe(entry) {
|
||||
const p = entry.payload || {}
|
||||
switch (entry.event_type) {
|
||||
case 'create_entity': return `${p.name || p.entity} enters the story`
|
||||
case 'set_entity_status': return `${p.entity} is ${p.status}`
|
||||
case 'set_entity_attribute': return `${p.entity} ${p.attribute} = ${p.value}`
|
||||
case 'set_entity_conditions': return `${p.entity}: ${(p.conditions || []).join(', ') || 'nothing'}`
|
||||
case 'set_current_location': return `${p.entity} is at ${p.location}`
|
||||
case 'set_possession': return `${p.owner} holds ${p.item}`
|
||||
case 'clear_possession': return `${p.item} is held by nobody`
|
||||
case 'add_fact': return [p.subject, p.predicate, p.object, p.value].filter(Boolean).join(' ')
|
||||
case 'invalidate_fact': return `withdrew ${p.fact_id}${p.reason ? ` — ${p.reason}` : ''}`
|
||||
case 'add_relationship': return `${p.source} ${p.relationship} ${p.target}`
|
||||
case 'end_relationship': return `${p.source} no longer ${p.relationship} ${p.target}`
|
||||
case 'open_story_thread': return `opened: ${p.title || p.thread}`
|
||||
case 'resolve_story_thread': return `resolved: ${p.thread}`
|
||||
case 'set_scene': return `scene: ${p.summary || p.location || ''}`
|
||||
default: return entry.event_type
|
||||
}
|
||||
}
|
||||
|
||||
export { StatePanel }
|
||||
@@ -236,10 +236,11 @@ export default function ScenarioEditor() {
|
||||
</div>
|
||||
</div>
|
||||
<p className="dim" style={{ margin: '0 0 10px', fontSize: '0.85rem' }}>
|
||||
Optional. Define stats (with bands and rules), flags, and milestones, and the AI
|
||||
will track them each turn — HP, mana, a raised alarm, quest objectives. NPCs are
|
||||
part of this same schema but have their own section below. Leave blank for a plain
|
||||
narrative scenario.
|
||||
Optional, and no longer how the story is tracked. Every campaign now keeps
|
||||
genre-neutral narrative state — who exists, where they are, what they hold,
|
||||
what has been established — without any schema at all, and that is what the
|
||||
narrator is told. Numbers defined here are recorded alongside it for
|
||||
scenarios that were built on them. Leave blank unless you have one.
|
||||
</p>
|
||||
{schemaView === 'form' ? (
|
||||
<SchemaEditor schema={parsedSchema} onChange={applySchema} />
|
||||
|
||||
@@ -255,6 +255,102 @@
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
/* Save Points (M4). Deliberately the same furniture as the branch panel: a Save
|
||||
Point is a name for a position, and it should not look like a different
|
||||
species of thing from the lines it names positions on. The confirmations
|
||||
borrow the branch panel's shape but not its danger colouring — restoring
|
||||
destroys nothing, and only deletion is red. */
|
||||
.save-point-panel { display: flex; flex-direction: column; gap: 14px; }
|
||||
.save-point-new { display: flex; flex-direction: column; gap: 7px; }
|
||||
.save-point-new label {
|
||||
font-size: 0.64rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.save-point-new input {
|
||||
padding: 5px 9px;
|
||||
font-family: var(--font-story);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.save-point-new input.save-point-note {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.save-point-hint, .save-point-intro {
|
||||
margin: 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.save-point-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.save-point-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
padding: 9px 11px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
background: var(--bg-input);
|
||||
}
|
||||
/* A Save Point naming a moment on a telling the story has left. Dimmed rather
|
||||
than hidden: it still restores, and hiding it would be the automatic cleanup
|
||||
this milestone deliberately does not do. */
|
||||
.save-point-row.elsewhere { opacity: 0.72; }
|
||||
.save-point-head { display: flex; align-items: center; gap: 7px; }
|
||||
.save-point-name {
|
||||
font-family: var(--font-story);
|
||||
font-size: 0.98rem;
|
||||
color: var(--text);
|
||||
}
|
||||
.save-point-rename {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 3px 7px;
|
||||
font-family: var(--font-story);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.save-point-meta {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-dim);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.save-point-note-text {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.save-point-tools, .save-point-confirm {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.save-point-tools button, .save-point-confirm button {
|
||||
padding: 3px 9px;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-dim);
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.save-point-tools button:hover:not(:disabled),
|
||||
.save-point-confirm button:hover:not(:disabled) {
|
||||
color: var(--accent-bright);
|
||||
border-color: var(--border-bright);
|
||||
}
|
||||
.save-point-tools button.danger:hover:not(:disabled),
|
||||
.save-point-confirm button.danger:hover:not(:disabled) {
|
||||
color: var(--danger);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
.save-point-tools button:disabled,
|
||||
.save-point-confirm button:disabled { opacity: 0.4; cursor: default; }
|
||||
.save-point-confirm span {
|
||||
font-size: 0.74rem;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.5;
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.action-edit { margin-bottom: 14px; }
|
||||
/* Sized by AutoTextarea to fit the text being edited — an AI beat is usually
|
||||
several paragraphs, and the old fixed 110px turned that into a keyhole.
|
||||
@@ -314,3 +410,93 @@
|
||||
}
|
||||
.mode-select button.active + button { border-left-width: 0; }
|
||||
|
||||
/* Story State (M5). The same furniture as the other panels: what the campaign
|
||||
believes is not a different species of thing from the lines it believes them
|
||||
on, and a reader moving between panels should not have to relearn the shapes. */
|
||||
.state-panel { display: flex; flex-direction: column; gap: 14px; }
|
||||
.state-intro, .state-hint {
|
||||
margin: 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.state-group { display: flex; flex-direction: column; gap: 6px; }
|
||||
.state-group h3 {
|
||||
margin: 0;
|
||||
font-size: 0.64rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-dim);
|
||||
font-weight: normal;
|
||||
}
|
||||
.state-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
background: var(--bg-input);
|
||||
}
|
||||
.state-label {
|
||||
font-family: var(--font-story);
|
||||
font-size: 0.94rem;
|
||||
color: var(--text);
|
||||
}
|
||||
.state-detail {
|
||||
font-size: 0.74rem;
|
||||
color: var(--text-dim);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.state-tools { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; }
|
||||
.state-tools button {
|
||||
padding: 2px 8px;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-dim);
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.state-tools button:hover:not(:disabled) {
|
||||
color: var(--accent-bright);
|
||||
border-color: var(--border-bright);
|
||||
}
|
||||
.state-tools button:disabled { opacity: 0.4; cursor: default; }
|
||||
.state-correction { display: flex; flex-direction: column; gap: 7px; }
|
||||
.state-correction form { display: flex; flex-direction: column; gap: 7px; }
|
||||
.state-correction label {
|
||||
font-size: 0.64rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.state-correction input {
|
||||
padding: 5px 9px;
|
||||
font-family: var(--font-story);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.state-correct-open, .state-history-open {
|
||||
align-self: flex-start;
|
||||
padding: 3px 9px;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-dim);
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.state-correct-open:hover, .state-history-open:hover {
|
||||
color: var(--accent-bright);
|
||||
border-color: var(--border-bright);
|
||||
}
|
||||
.state-history { display: flex; flex-direction: column; gap: 4px; }
|
||||
.state-history-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
padding: 5px 9px;
|
||||
border-left: 2px solid var(--border);
|
||||
}
|
||||
.state-history-what { font-size: 0.8rem; color: var(--text); }
|
||||
.state-history-when {
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-dim);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@@ -358,10 +358,16 @@ Each entry:
|
||||
|
||||
```text
|
||||
Before entering the abbey
|
||||
Turn 42
|
||||
Moment 42
|
||||
[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
|
||||
|
||||
Restoring is non-destructive.
|
||||
|
||||
+175
-10
@@ -1,6 +1,6 @@
|
||||
# Adventure Storyteller — Production Build Milestones
|
||||
|
||||
**Status:** In implementation. M1 and M2 complete (2026-09-02); M3 next
|
||||
**Status:** In implementation. M1-M5 complete and accepted (M1 and M2: 2026-09-02; M3 and M4: 2026-09-03; M5: 2026-09-04, after an independent review and a corrective pass); M6 — Branch-Safe Context, Summaries, and Long-Term Story Memory — next to brief
|
||||
**Base:** AI-DnD `d72f7c1bda0f34fccd84afb7a25c34eb01c901de`
|
||||
|
||||
## 1. Purpose
|
||||
@@ -250,7 +250,7 @@ History operations are non-destructive, Redo works, divergence preserves old fut
|
||||
|
||||
## 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,
|
||||
so that document carries the raw counts and runtime observations as well as the
|
||||
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
|
||||
`STORY-BRANCH-SEMANTICS.md` (§5, §10, §14A).
|
||||
|
||||
**Outstanding closeout condition:** the required **browser smoke test has not
|
||||
been performed** — no session in which M3 was implemented or reviewed had a
|
||||
browser available. The equivalent sequence was driven end-to-end against the
|
||||
running application with real inference and a process restart, and every
|
||||
server-side behaviour it covers passes; the DOM-level behaviour of the Redo
|
||||
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
|
||||
remains an open M3 item until a human runs it.
|
||||
**Closeout condition — CLOSED at M4 closeout (2026-09-03).** M3 was accepted with
|
||||
one condition outstanding: the required **browser smoke test had not been
|
||||
performed**, because no session in which M3 was implemented or reviewed had a
|
||||
browser available, leaving the DOM-level behaviour of the Redo button and its
|
||||
disabled states unverified by observation.
|
||||
|
||||
That condition is now discharged. A real Firefox 154.0.1, driven through
|
||||
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
|
||||
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
|
||||
"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
|
||||
@@ -472,6 +591,52 @@ treat the edited text as accepted output, re-evaluate the implied state, create
|
||||
new continuation, and retain the original. The refusal in §14A is then replaced
|
||||
by that behavior rather than kept alongside it.
|
||||
|
||||
**Delivered in the M5 corrective pass.** The first M5 implementation did the
|
||||
state half only and kept editing the row in place, which the independent review
|
||||
found broke the head/state invariant. A narrator edit now forks — the original
|
||||
narration and its whole future are retained untouched, and the correction
|
||||
becomes a new active continuation carrying its own re-derived state. The §14A
|
||||
refusal is gone for narrator turns and remains only for a player's own input
|
||||
(§13), which M5 did not change.
|
||||
|
||||
---
|
||||
|
||||
## M5 — Outcome
|
||||
|
||||
**Complete and accepted, 2026-09-04**, after an independent implementation
|
||||
review (`planning/reports/M5-IMPLEMENTATION-REPORT.md`) and the corrective pass
|
||||
recorded in that report's addendum.
|
||||
|
||||
Delivered:
|
||||
|
||||
- Genre-neutral typed narrative state per ADR 010, with the authoritative
|
||||
document shape now recorded in
|
||||
[ADR 013](DECISIONS/013-authoritative-narrative-state-document.md).
|
||||
- **D10 complete.** A narrator edit returns to the state before the turn, uses
|
||||
the reader's exact text, re-derives the state it implies, becomes a new active
|
||||
continuation, and retains the original narration and its future
|
||||
(`STORY-BRANCH-SEMANTICS.md` §§14-15).
|
||||
- **Pre-M5 compatibility.** Migration 88 backfills the empty narrative document
|
||||
onto every action written before M5, and a missing snapshot restores the empty
|
||||
document rather than leaving a later position's state standing. Old campaigns,
|
||||
Save Points, branches and transcripts stay usable, and no legacy RPG machinery
|
||||
becomes authoritative again.
|
||||
- **Manual corrections govern the narrator's context.** Replayed history carries
|
||||
prose only, and a withdrawn fact is named as no longer true rather than
|
||||
silently dropped.
|
||||
|
||||
Debt carried forward, deliberately:
|
||||
|
||||
- **M8 (browser UX):** the scenario editor still exposes the legacy RPG stat
|
||||
schema. Its copy no longer claims that schema is how the story is tracked, but
|
||||
the screen itself is M8's to redesign.
|
||||
- **M9 (export/recovery):** `state_events` and `state_proposals` are not carried
|
||||
in an export, so an imported campaign keeps a correction's effect but not its
|
||||
audit trail. C04 therefore passes for a live campaign and not across a round
|
||||
trip.
|
||||
- **§13 (editing player input)** is still an in-place edit guarded by the
|
||||
off-screen refusal. Bringing it onto the §§14-15 footing was out of M5's scope.
|
||||
|
||||
---
|
||||
|
||||
# M6 — Branch-Safe Context, Summaries, and Long-Term Story Memory
|
||||
|
||||
+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.
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
# ADR 013 — The Authoritative Narrative State Document
|
||||
|
||||
**Status:** Accepted; implemented in M5
|
||||
**Date:** 2026-09-04
|
||||
|
||||
## Context
|
||||
|
||||
[ADR 010](010-explicit-typed-narrative-state-events.md) settled the **protocol**:
|
||||
the model proposes change as explicit, typed, absolute events drawn from a fixed
|
||||
allowlist, never as relative deltas. It did not settle what those events write
|
||||
into.
|
||||
|
||||
That shape turned out to be load-bearing. The prompt renders it, the browser's
|
||||
state panel groups it, export carries it, migration has to produce it for
|
||||
positions that predate it, and every Undo, Redo, take switch and Save Point
|
||||
restore reads a copy of it. The M5 review recommended recording it as a decision
|
||||
rather than leaving it an implementation detail of `narrative/model.py`. This ADR
|
||||
records what was built; it does not extend it.
|
||||
|
||||
## Decision
|
||||
|
||||
### The document
|
||||
|
||||
One JSON document is the campaign's authoritative account of its own story. It
|
||||
is genre-neutral: nothing in it names a stat, a level, a currency or a class.
|
||||
|
||||
```text
|
||||
entities who and what exists — people, places, things, groups.
|
||||
Each carries a name, a type, aliases, a description, a status,
|
||||
free-form attributes, conditions, and a current location.
|
||||
facts what has been established, as subject/predicate/object.
|
||||
Each carries an id, a status, and where it came from.
|
||||
relationships how entities stand to one another, directionally.
|
||||
possessions which entity holds which item.
|
||||
threads open story threads, with a title and a status.
|
||||
scene where the story is now, and a short summary of the moment.
|
||||
```
|
||||
|
||||
Two fields appear throughout and are the reason the document can be trusted:
|
||||
|
||||
- **authority** — who established this: the story, or the reader's own
|
||||
correction. A reader's correction outranks the narration, and the prompt says
|
||||
so in words.
|
||||
- **provenance** — the branch and depth the change was made at, so a fact can be
|
||||
traced to the moment it entered the story.
|
||||
|
||||
Nothing is deleted. A fact a correction takes back is marked `invalidated`, with
|
||||
the reason and the position, because a record that vanished would audit nothing.
|
||||
|
||||
### The pipeline
|
||||
|
||||
```text
|
||||
model output
|
||||
-> extraction the protocol block is separated from the prose
|
||||
-> validation envelope, allowlist, schema, references, canon
|
||||
-> validated events recorded in `state_events`, with their provenance
|
||||
-> the document applied to produce the new authoritative state
|
||||
-> per-position snapshot stored on the node the turn produced
|
||||
```
|
||||
|
||||
Each stage has one job, and the order is deliberate: the allowlist is checked
|
||||
before any field is read, so an unknown event type is rejected before its
|
||||
contents are touched.
|
||||
|
||||
### Where it lives
|
||||
|
||||
- `adventures.narrative_state` — the current authoritative document. This is
|
||||
what the narrator is told and what the reader is shown.
|
||||
- `actions.narrative_state_after` — the whole document as it stood after that
|
||||
position played, on **every** node.
|
||||
- `state_events` / `state_proposals` — the audit: what was proposed, what was
|
||||
accepted, what was refused and why.
|
||||
|
||||
### What each is for
|
||||
|
||||
**Event history is audit and provenance, not a source of truth.** Restoring a
|
||||
position never replays it. This was proven by renaming the table away
|
||||
mid-campaign: Undo and Redo continued to work.
|
||||
|
||||
**Snapshots are what make restore bounded.** Arriving at a position is a single
|
||||
row read whose cost does not grow with the length of the story (ADR 012 §10.4).
|
||||
A position without a snapshot is a position the head cannot be restored to, so
|
||||
every node has one — including nodes written before M5, which migration 88
|
||||
backfills with the empty document. A missing snapshot restores the empty
|
||||
document rather than leaving the previous position's state standing.
|
||||
|
||||
**The document is the authority; the model only proposes.** Every event, whether
|
||||
it came from the model or from a reader's correction, passes the same
|
||||
validation. The model cannot write a field directly, cannot invent an event
|
||||
type, and cannot refer to an entity that does not exist in this campaign.
|
||||
|
||||
## The invariant
|
||||
|
||||
```text
|
||||
visible active transcript position == stored head == authoritative state
|
||||
```
|
||||
|
||||
Everything above exists to hold this. The M5 review found two ways it had been
|
||||
broken — a narrator edit that rewound live state while the head stayed at the
|
||||
tip, and a restore to a migrated position that left a later position's state
|
||||
standing — and both were fixed by making the rule absolute rather than by adding
|
||||
a special case.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The document is larger than the RPG dict it replaced, so both it and the
|
||||
per-node snapshots are stored compressed.
|
||||
- Genre lives in the campaign's canon and in the words the story uses, never in
|
||||
the schema. A science-fiction campaign and a fantasy one produce the same
|
||||
shapes.
|
||||
- A reader's correction is durable and outranks narration, and the prompt states
|
||||
both what holds and what has been withdrawn.
|
||||
- Export carries the document, the canon and every snapshot. It does **not**
|
||||
yet carry `state_events` / `state_proposals`, so an imported campaign keeps a
|
||||
correction's effect but not its audit trail. Deferred to M9.
|
||||
@@ -82,16 +82,16 @@ in `planning/archive/decisions/`.
|
||||
One file, and it changes as development progresses:
|
||||
|
||||
```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.
|
||||
This report is M3's review *and* its primary evidence record — no separate M3
|
||||
baseline report was produced — so it is the only place some of what M3 left
|
||||
behind is written down, including the browser smoke test M3 still owes.
|
||||
M4 is the most recently completed milestone, and M5 is the next to be briefed.
|
||||
This report is M4's review *and* its closeout record: its §W holds the corrective
|
||||
work, the process-boundary automation, and the real-browser verification that
|
||||
closed the condition M3 and M4 both carried.
|
||||
|
||||
**Replace it, do not accumulate.** When M4's report lands, remove this one from
|
||||
the project Sources and upload M4's instead. The repository does the same thing:
|
||||
**Replace it, do not accumulate.** When M5's report lands, remove this one from
|
||||
the project Sources and upload M5's instead. The repository does the same thing:
|
||||
`planning/reports/` holds the current milestone's report and
|
||||
`planning/archive/milestone-reports/` holds the rest.
|
||||
|
||||
|
||||
+24
-22
@@ -3,9 +3,10 @@
|
||||
**This file is the index. Start here.**
|
||||
|
||||
**Current state:** Phase 0 complete; AI-DnD forked as the production base;
|
||||
milestones **M1, M2 and M3 implemented and accepted** (M3: 2026-09-03).
|
||||
**Next:** **M4 — named Save Points.** Its brief has not been written yet, and
|
||||
writing it is the current action.
|
||||
milestones **M1, M2, M3 and M4 implemented and accepted** (M3 and M4:
|
||||
2026-09-03).
|
||||
**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
|
||||
and why.
|
||||
@@ -95,7 +96,7 @@ Two standing qualifications:
|
||||
10. `BROWSER-UX-SPEC.md`
|
||||
11. `V1-ACCEPTANCE-TESTS.md`
|
||||
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.
|
||||
|
||||
## 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
|
||||
that is the one the next milestone's planning has to consult:
|
||||
|
||||
- `reports/M3-IMPLEMENTATION-REPORT.md` — M3's review **and** its primary
|
||||
evidence record; no separate M3 baseline report was produced. M4 needs its
|
||||
§W (closeout) and §M/§W.4 (the open browser smoke test).
|
||||
- `reports/M4-IMPLEMENTATION-REPORT.md` — M4's review and its evidence record.
|
||||
|
||||
Completed earlier milestones are in `archive/milestone-reports/`. When M4's
|
||||
report lands, M3's moves there too: a milestone report is useful during the
|
||||
immediate next milestone and historical afterwards.
|
||||
Completed earlier milestones are in `archive/milestone-reports/`, which M3's
|
||||
report joined when M4's landed: a milestone report is useful during the
|
||||
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
|
||||
|
||||
@@ -230,13 +230,13 @@ Milestone M2 COMPLETE (2026-09-02)
|
||||
|
|
||||
v
|
||||
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
|
||||
|
|
||||
v
|
||||
Milestone M4 NEXT — brief not yet prepared
|
||||
named Save Points
|
||||
|
|
||||
Milestone M4 COMPLETE (2026-09-03)
|
||||
named Save Points reports/M4-IMPLEMENTATION-REPORT.md
|
||||
| browser verification: PASS (M3 + M4)
|
||||
v
|
||||
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.**
|
||||
|
||||
**No M4 brief has been prepared.** Writing one is the current action, informed
|
||||
by the post-M3 corrections below, by the note `BUILD-MILESTONES.md` attaches to
|
||||
M4, and by **ADR 012**, which records the head-movement mechanism M4 must reuse
|
||||
rather than reimplement.
|
||||
**No M5 brief has been prepared.** Writing one is the current action, informed by
|
||||
the M4 report's §U readiness assessment and by the note `BUILD-MILESTONES.md`
|
||||
attaches to M5 — in particular that M4 added 55 tests using the inherited
|
||||
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
|
||||
smoke test has not been performed**, because no session in which M3 was
|
||||
implemented or reviewed had a browser available. See
|
||||
`reports/M3-IMPLEMENTATION-REPORT.md` §M and §W.4.
|
||||
**No conditions remain open on M1-M4.** The browser smoke condition that M3 and
|
||||
M4 both carried was satisfied at M4 closeout: a real Firefox exercised the
|
||||
rendered DOM for both milestones' controls, 44/44 checks passing. The M3 report's
|
||||
§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
|
||||
|
||||
|
||||
@@ -377,37 +377,69 @@ Therefore the system must:
|
||||
|
||||
The system must not simply replace visible text while leaving stale state behind.
|
||||
|
||||
## 14A. Editing In Place, Before §14-15 Are Implemented
|
||||
### How this is built (M5 corrective pass)
|
||||
|
||||
§14 and §15 describe the finished behavior: a narrator edit becomes
|
||||
authoritative, the state it implies is re-evaluated, a new continuation is
|
||||
created, and the original narration and its future are retained. That
|
||||
requirement stands in full and is **not** weakened by this section.
|
||||
All five conditions are implemented. A narrator edit is not a write to the row
|
||||
being corrected — nothing on the line being left is written to at all. It is the
|
||||
same operation as playing the turn again from here, with the reader's words in
|
||||
place of a generated reply, and it reuses the fork, take, head and snapshot
|
||||
machinery M3 and M4 already provide rather than introducing a second history
|
||||
mechanism.
|
||||
|
||||
It is not yet built. Re-evaluating the state implied by prose a user typed
|
||||
requires the authoritative narrative-state extraction that the genre-neutral
|
||||
state milestone introduces, so the finished behavior is completed there. What
|
||||
exists in the meantime is a plain correction: it changes the words of one turn
|
||||
and re-evaluates nothing.
|
||||
```text
|
||||
before after
|
||||
|
||||
That correction is safe while everything descending from the turn is on screen,
|
||||
because the user can see what their change has to stay consistent with. It is
|
||||
not safe when a continuation descends from the turn and is **off screen** —
|
||||
undone and not yet redone, or left behind by a divergence — because the edit
|
||||
would then silently change the words that retained story was written from, and
|
||||
nothing on screen would show it. Retained history is not permitted to be made to
|
||||
disagree with itself in a way the user cannot see.
|
||||
parent parent
|
||||
└── original narrator ├── original narrator ──> old future [retained]
|
||||
└── old future └── corrected narrator [active]
|
||||
```
|
||||
|
||||
Until §14-15 are implemented, the system must therefore **refuse** an in-place
|
||||
edit of a turn that has story descending from it which is not currently being
|
||||
shown, and say why. The user resolves it the same two ways as §10:
|
||||
Two shapes, chosen by whether anything was written after the turn:
|
||||
|
||||
- **Redo**, bringing the later story back into view; or
|
||||
- **play the turn again from here**, which is the §13 shape — return to the
|
||||
parent position, continue differently, and keep the old line as retained
|
||||
history.
|
||||
- **Nothing below it.** The attempts at that turn are still leaves, so the
|
||||
correction joins them as another take and the original stays beside it in the
|
||||
pager. No branch is created, and the head does not move.
|
||||
- **A story below it**, on screen or not. That story was written as a
|
||||
continuation of the words that are there now, so it keeps them: the correction
|
||||
leaves the path just before the turn, and the departed line keeps its node,
|
||||
its future and its live flag.
|
||||
|
||||
Refusing is the minimum that keeps the invariant. It is not the destination.
|
||||
The state the correction implies is derived from the snapshot on the node
|
||||
*before* the edited turn — one row read, not a replay — and put through the same
|
||||
validation the model's own proposals face. The correction's node then carries
|
||||
that document as its own outcome, so the campaign's live state and the state
|
||||
stored at the head are the same document. That equality is the invariant:
|
||||
|
||||
```text
|
||||
visible active transcript position == stored head == authoritative state
|
||||
```
|
||||
|
||||
The M5 review found it broken by the interim implementation, which rewrote the
|
||||
row in place and rewound the campaign's live state to that position while the
|
||||
head stayed at the tip. The reader saw a full transcript over a state document
|
||||
describing an earlier moment, and the snapshots below the edit still described
|
||||
prose that no longer existed.
|
||||
|
||||
## 14A. Editing In Place — Superseded for Narrator Output
|
||||
|
||||
§§14-15 are implemented, and the refusal this section described no longer
|
||||
applies to narrator turns. It is kept because it explains why the current shape
|
||||
is the one it is.
|
||||
|
||||
The refusal existed because an in-place edit rewrote the words that a
|
||||
descending, invisible story had been written from, and retained history is not
|
||||
permitted to be made to disagree with itself where the user cannot see it. A
|
||||
fork removes the premise: the off-screen future keeps the exact narration it
|
||||
descends from, so there is nothing left to refuse. What was the unsafe case is
|
||||
now an ordinary one.
|
||||
|
||||
Two in-place edits remain, and neither can produce that disagreement:
|
||||
|
||||
- **A player's own input** (§13) is still edited in place, and is still refused
|
||||
while a story descends from it off screen. Bringing §13 onto the same footing
|
||||
as §§14-15 is not yet done.
|
||||
- **A take the story is not telling** has no continuation of its own — keeping
|
||||
one is what forking is for — so correcting its words contradicts nothing.
|
||||
|
||||
## 16. Manual State / Canon Correction
|
||||
|
||||
@@ -467,6 +499,39 @@ They should not be removed by:
|
||||
- branch divergence,
|
||||
- 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
|
||||
|
||||
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
|
||||
§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
|
||||
|
||||
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 —
|
||||
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.1 Do not retain the RPG state protocol as the product model
|
||||
|
||||
+138
-19
@@ -1,8 +1,28 @@
|
||||
# Adventure Storyteller — V1 Acceptance Tests
|
||||
|
||||
**Status:** v1.2 planning/release contract — updated after Phase 0B, after M2 for
|
||||
the security contract (H10 strengthened, H12 added), and after M3 for history
|
||||
ownership and results (D03, D10, I07, L01)
|
||||
**Status:** v1.3 planning/release contract — updated after Phase 0B, after M2 for
|
||||
the security contract (H10 strengthened, H12 added), after M3 for history
|
||||
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.
|
||||
|
||||
## 1. Test Philosophy
|
||||
@@ -497,6 +517,31 @@ Mara never learned where the silver key was found.
|
||||
- correction is auditable,
|
||||
- old transcript is not silently rewritten unless explicitly edited.
|
||||
|
||||
### Result — PASS for the live campaign (M5 corrective pass, 2026-09-04)
|
||||
|
||||
The M5 review found the first condition failing: the state section dropped a
|
||||
withdrawn fact and the replayed history handed it straight back as an accepted
|
||||
event, in the protocol's own words, with nothing saying it had been corrected.
|
||||
Two changes fixed it, and both are pinned by
|
||||
`test_c04_a_withdrawn_fact_does_not_come_back_through_history`:
|
||||
|
||||
- replayed history is prose only — the machine-readable block is no longer
|
||||
reconstructed into past turns, so a turn's record of what was true *then*
|
||||
cannot contradict what is authoritative now;
|
||||
- a withdrawn fact is named in the state section under "No longer true — do not
|
||||
treat these as established", with the reader's reason, rather than silently
|
||||
omitted. Omitting it left the narration that first asserted it as the only
|
||||
account in the prompt.
|
||||
|
||||
The old transcript is not rewritten: an invalidated fact stays in the document
|
||||
with its status, its reason and its provenance.
|
||||
|
||||
**Carried debt, deferred to M9 (export/recovery):** a campaign's `state_events`
|
||||
and `state_proposals` are not carried in an export, so an imported copy keeps
|
||||
the correction's *effect* — the fact is still marked `manual_correction` — but
|
||||
reports zero audit events. The auditable condition therefore holds for a live
|
||||
campaign and not across a round trip.
|
||||
|
||||
---
|
||||
|
||||
## C05 — Canon Beats Reference
|
||||
@@ -701,25 +746,41 @@ Mara wears a green cloak.
|
||||
- downstream state is re-evaluated,
|
||||
- old version/future remains retained/disposable.
|
||||
|
||||
### Milestone ownership
|
||||
### Result — PASS (M5 corrective pass, 2026-09-04)
|
||||
|
||||
All three pass conditions stand for v1. They are delivered across three
|
||||
milestones, and this note records which is which rather than reducing the
|
||||
requirement:
|
||||
All three pass conditions are met, and each was demonstrated rather than
|
||||
inferred.
|
||||
|
||||
- **Edit becomes authoritative on the active path.** The reader's text is stored
|
||||
verbatim, with only the protocol block stripped, and no model is called
|
||||
(`test_the_corrected_text_is_used_verbatim_not_regenerated`).
|
||||
- **Downstream state is re-evaluated.** The state is derived from the snapshot on
|
||||
the node before the corrected turn and put through the normal validation path,
|
||||
and the campaign's live document then equals the document stored at the head
|
||||
(`test_editing_a_narrator_turn_with_visible_descendants_forks`, and the
|
||||
live-state/head-snapshot assertion carried by every test in that group).
|
||||
- **Old version/future remains retained/disposable.** Nothing on the departed
|
||||
line is written to: the original node keeps its words and its live flag, and
|
||||
every row played after it still exists
|
||||
(`test_the_old_narration_and_its_future_leave_the_active_transcript`,
|
||||
`test_editing_a_narrator_turn_with_an_undone_future_keeps_it`).
|
||||
|
||||
Verified in a real browser on the case the M5 review reproduced as broken:
|
||||
correcting the earliest narrator turn with four turns of story on screen, then
|
||||
checking the transcript, the retained rows, the state panel, the live document
|
||||
against the head snapshot, Undo/Redo, and the next turn's assembled prompt —
|
||||
16 of 16 checks.
|
||||
|
||||
The delivery history, kept because it explains the shape:
|
||||
|
||||
- **M3 — safe history behavior.** Replaying a narrator turn with different text
|
||||
forks, keeps the original take and its future, and starts the new continuation
|
||||
from the correct earlier state. In-place editing of a turn is **refused** while
|
||||
story descends from it off screen, so retained history cannot be made to
|
||||
contradict itself unseen (`STORY-BRANCH-SEMANTICS.md` §14A). Delivered.
|
||||
- **M5 — authoritative state re-evaluation.** The second pass condition. Making a
|
||||
hand-typed narrator correction authoritative and re-evaluating the state it
|
||||
implies requires the narrative-state extraction pass, so it is completed there,
|
||||
and the §14A refusal is replaced by it. Outstanding.
|
||||
- **Later browser UX work — the finished editing workflow.** How the user reaches
|
||||
and confirms the operation. Outstanding.
|
||||
|
||||
D10 is therefore **not** satisfied at the end of M3, and is not scored as such.
|
||||
forks and keeps the original take and its future. In-place editing was
|
||||
*refused* while story descended from a turn off screen.
|
||||
- **M5 — authoritative state re-evaluation**, and the refusal replaced. A
|
||||
narrator edit now forks rather than rewriting a row, so the off-screen case it
|
||||
refused is simply handled (`STORY-BRANCH-SEMANTICS.md` §§14-15).
|
||||
- **Later browser UX work.** How the reader reaches and confirms the operation
|
||||
is M8's; the operation itself is complete and reachable through the ✎ control.
|
||||
|
||||
---
|
||||
|
||||
@@ -737,6 +798,14 @@ Before entering the abbey
|
||||
### Pass
|
||||
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
|
||||
@@ -751,6 +820,11 @@ Checkpoint persists across application restart.
|
||||
### Pass
|
||||
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
|
||||
@@ -760,6 +834,13 @@ Transcript/state return to checkpoint position.
|
||||
### Pass
|
||||
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
|
||||
@@ -773,10 +854,31 @@ Delete named checkpoint.
|
||||
- checkpoint pointer disappears,
|
||||
- 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
|
||||
|
||||
> **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
|
||||
|
||||
**Priority:** REQUIRED FOR V1
|
||||
@@ -1353,6 +1455,15 @@ Export preserves retained alternate/disposable history needed for recovery, unle
|
||||
### Pass
|
||||
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
|
||||
@@ -1581,6 +1692,14 @@ State at each position matches original accepted state.
|
||||
### Pass
|
||||
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
|
||||
|
||||
+124
-3
@@ -1,8 +1,127 @@
|
||||
# Planning Package Version
|
||||
|
||||
- **Package:** Adventure Storyteller Planning Package v2.4
|
||||
- **Package:** Adventure Storyteller Planning Package v2.6
|
||||
- **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)
|
||||
|
||||
@@ -43,7 +162,7 @@ tell authoritative material from evidence at a glance.
|
||||
## v2.3 — Post-M3 Closeout (2026-09-03)
|
||||
|
||||
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
|
||||
closeout.
|
||||
|
||||
@@ -153,6 +272,8 @@ Key v2 changes include:
|
||||
- AI-DnD selected as the production base at the pinned Phase 0B commit.
|
||||
- Non-destructive head-cursor Undo/Redo design selected.
|
||||
- Explicit typed/absolute narrative-state events selected for production state handling.
|
||||
- The authoritative narrative-state document — its shape, its authority/provenance fields, and the
|
||||
event/document/snapshot split — recorded in ADR 013 after M5 implemented it.
|
||||
- Imported knowledge separated from AI-DnD Story Cards.
|
||||
- Trusted-LAN Ollama inference supported in v1 while the storyteller UI/API remains loopback-bound by default.
|
||||
- Offline first-use dependencies and runtime remote assets identified as M1 hardening work.
|
||||
|
||||
@@ -41,7 +41,11 @@ Git history.
|
||||
### `milestone-reports/` — completed milestone evidence
|
||||
|
||||
`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
|
||||
the active planning documents and the ADRs — see `planning/VERSION.md`, which
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -26,4 +26,4 @@ planning/DECISIONS/009-ai-dnd-production-base.md
|
||||
planning/DECISIONS/010-explicit-typed-narrative-state-events.md
|
||||
planning/DECISIONS/011-local-inference-endpoint-policy.md
|
||||
planning/DECISIONS/012-active-head-non-destructive-history.md
|
||||
planning/reports/M3-IMPLEMENTATION-REPORT.md
|
||||
planning/reports/M4-IMPLEMENTATION-REPORT.md
|
||||
|
||||
@@ -0,0 +1,956 @@
|
||||
# M5 Implementation Review — Typed Narrative State
|
||||
|
||||
**Reviewed:** 2026-09-03 / 2026-09-04
|
||||
**Branch:** `m5-narrative-state` **Base commit:** `62a997f` (M4 closeout)
|
||||
**Subject of review:** the staged, uncommitted M5 working tree
|
||||
**Verdict:** PASS WITH CORRECTIVE WORK REQUIRED
|
||||
|
||||
---
|
||||
|
||||
## A. Scope
|
||||
|
||||
This reviews the M5 milestone: replacing the RPG relative-delta world-state
|
||||
mechanism with genre-neutral typed narrative state per ADR 010, and making that
|
||||
state the authoritative thing the narrator is told and the reader is shown.
|
||||
|
||||
It is a review, not a closeout and not implementation. No application code was
|
||||
changed. Defects found are recorded here rather than fixed, and the working tree
|
||||
is exactly as the implementation left it.
|
||||
|
||||
M6 was not started.
|
||||
|
||||
## B. Method and independence
|
||||
|
||||
The M5 implementation summary was treated as a claim, not as evidence. Every
|
||||
acceptance statement below rests on something run during this review:
|
||||
|
||||
- the full backend suite, re-run from scratch;
|
||||
- purpose-built probes that drive the real FastAPI application over its real
|
||||
HTTP surface against a real SQLite file, with a scripted narrator;
|
||||
- a real Firefox 154.0.1 driven over WebDriver against the real built frontend
|
||||
served by the real backend;
|
||||
- the real model endpoint on the trusted LAN, for the tests that are skipped
|
||||
without one;
|
||||
- a real `docker build`.
|
||||
|
||||
Where a check could only be done with a mock, it is labelled as such. The
|
||||
atomicity check (L01) specifically was **not** done with mocks — the failure was
|
||||
induced at the storage layer.
|
||||
|
||||
## C. Repository state
|
||||
|
||||
```
|
||||
branch m5-narrative-state
|
||||
HEAD 62a997f
|
||||
staged 45 files changed, 4979 insertions(+), 364 deletions(-)
|
||||
12 added, 33 modified
|
||||
unstaged / untracked none
|
||||
```
|
||||
|
||||
The tree was in this state at the start of the review and is in this state at
|
||||
the end of it. No secrets, databases, or browser profiles are staged. Upstream
|
||||
ancestry is intact and `LICENSE` is unmodified.
|
||||
|
||||
The new package is `backend/app/narrative/` — `model.py`, `events.py`,
|
||||
`validate.py`, `apply.py`, `extract.py`, `render.py`, `store.py` — plus
|
||||
`backend/tests/test_narrative_state.py` and `test_narrative_realistic.py`.
|
||||
|
||||
## D. What M5 changed
|
||||
|
||||
The relative-delta protocol (`{"player.hp": -15}`) is gone from the prompt path.
|
||||
In its place is a 14-event allowlist of explicit, **absolute** narrative events
|
||||
(`create_entity`, `set_entity_attribute`, `set_possession`,
|
||||
`set_current_location`, `add_fact`, `invalidate_fact`, `open_story_thread`, …)
|
||||
carried in a fenced block the extraction pass strips out of the prose.
|
||||
|
||||
State is stored twice on purpose, per DATA-MODEL §17: validated events in
|
||||
`state_events` for audit, and a whole-document snapshot per position in
|
||||
`actions.narrative_state_after` for restore. The live document sits in
|
||||
`adventures.narrative_state`.
|
||||
|
||||
The legacy world-state module still exists and is still exercised by its own
|
||||
tests, but it no longer reaches the assembled prompt. That demotion is verified
|
||||
(§I below), not assumed.
|
||||
|
||||
## E. ADR 010 conformance
|
||||
|
||||
Conformant, and the central ambiguity ADR 010 exists to remove is genuinely
|
||||
removed. There is no code path where one numeric value can be read as either an
|
||||
absolute or a delta: `apply.py` is an explicit `if/elif` chain over event types,
|
||||
each writing a named field, with no arithmetic on prior values.
|
||||
|
||||
The state document is genre-neutral. A scan of the new package for RPG and
|
||||
fantasy vocabulary (`dungeon`, `hp`, `mana`, `quest`, `loot`, `potion`, …)
|
||||
returns nothing but one citation of AI-DnD in a module docstring explaining what
|
||||
ADR 010 replaced.
|
||||
|
||||
## F. Validation and the security boundary
|
||||
|
||||
`validate.py` is five layers — envelope, allowlist, schema, referential,
|
||||
semantic — and the allowlist is checked before anything else touches the
|
||||
payload. This is the right order: an unknown event type is rejected before its
|
||||
fields are read.
|
||||
|
||||
**H05 holds.** Hostile proposals were fed through the real turn pipeline:
|
||||
`execute_shell`, an `eval` event carrying `__import__('os').system(...)`, an
|
||||
event keyed `event_type` instead of `type` to test envelope confusion, an entity
|
||||
named `__class__`, and a story-thread id of `../../etc/passwd`. No marker file
|
||||
was created, no event was accepted, and the proposal was recorded with its
|
||||
rejection reasons. Nothing in the event path reaches the filesystem, a
|
||||
subprocess, or an attribute lookup on a Python object.
|
||||
|
||||
Cross-campaign references are refused: an event naming an entity that exists in
|
||||
a *different* campaign is rejected as `unknown_reference` rather than resolved.
|
||||
|
||||
## G. Head, lineage, and restore
|
||||
|
||||
**Restore does not replay the event log.** This was proven, not inferred: the
|
||||
`state_events` table was renamed away mid-campaign, and undo and redo both still
|
||||
returned 200 and moved the state correctly. Restore is a snapshot row lookup, as
|
||||
ADR 012 requires. Query counts for undo were also measured at two depths and did
|
||||
not grow.
|
||||
|
||||
**Lineage isolation holds (E01, E04).** State established on a line that is then
|
||||
abandoned does not appear in the new line's state document, and does not appear
|
||||
in the next turn's assembled prompt.
|
||||
|
||||
## H. Atomicity (L01)
|
||||
|
||||
Tested by induced failure, not by mocks: the `state_events` table was renamed
|
||||
away underneath a live state-writing turn.
|
||||
|
||||
```
|
||||
before head (1,2) mara.cloak = red
|
||||
state_events taken away
|
||||
turn OperationalError: no such table: state_events
|
||||
after head (1,3) mara.cloak = red
|
||||
accepted narration rows for the failed turn: 0
|
||||
previous story reachable: yes
|
||||
recovery after the table returns: next turn 200, state advances normally
|
||||
```
|
||||
|
||||
No half-written state, no accepted narration without it, no inaccessible
|
||||
history. The head advanced by exactly one, onto the player's own submitted text
|
||||
— which the L01 note explicitly defines as correct under A05, not as a
|
||||
half-advanced head. **L01 PASS.**
|
||||
|
||||
## I. Context assembly
|
||||
|
||||
The narrator is given the state document rendered as prose, not as JSON, and the
|
||||
legacy world-state block never appears. Verified by inspecting stored
|
||||
`context_snapshot` rows from real turns.
|
||||
|
||||
One defect here, and it is the mechanism behind Finding 4: the **history**
|
||||
section replays each past narrator turn's raw text *including its ```state
|
||||
fence*. So the prompt simultaneously instructs the model that prose must not
|
||||
contain protocol, and shows it several past examples where prose does.
|
||||
|
||||
## J. Manual corrections (C04)
|
||||
|
||||
A correction lands, is stored with `manual_correction` authority, is shown in
|
||||
the inspector marked as the reader's own, and is visible in the audit view.
|
||||
|
||||
But it is only half-applied to the narrator's context. With the fact
|
||||
`mara-knows / "knows where the key was found"` withdrawn by an explicit
|
||||
correction:
|
||||
|
||||
```
|
||||
state section contains the withdrawn fact: False ← correct
|
||||
full prompt contains the withdrawn assertion: True ← in the history replay
|
||||
any statement anywhere that it was corrected: False
|
||||
```
|
||||
|
||||
The state section drops it; the history section replays the original `add_fact`
|
||||
verbatim; and nothing in the prompt tells the model the assertion was
|
||||
withdrawn. The narrator is left holding the contradicted claim with no
|
||||
indication it is contradicted. **C04 PARTIAL.**
|
||||
|
||||
## K. Export / import
|
||||
|
||||
Round-tripping a campaign preserves the state document, campaign canon, save
|
||||
points, head depth (including an undone head, so I07 holds), every per-node
|
||||
snapshot (7 of 7), and correction *authority* — an imported corrected fact is
|
||||
still marked `manual_correction`.
|
||||
|
||||
It does not preserve the audit trail. `state_events` and `state_proposals` are
|
||||
not in the bundle, so an imported campaign reports **0 audit events**. The
|
||||
correction's effect survives; the record of who made it and why does not. This
|
||||
is the round-trip half of C04's "auditable" condition.
|
||||
|
||||
## L. Migration from pre-M5
|
||||
|
||||
A genuine pre-M5 database was constructed (M5 tables dropped, columns removed,
|
||||
`user_version` stamped back) and then migrated. Migration itself is clean: the
|
||||
campaign opens, all rows and save points survive, and new M5 turns play
|
||||
normally.
|
||||
|
||||
The defect is at the seam. Pre-M5 nodes have no snapshot, and
|
||||
`attempts.restore_state` treats a NULL snapshot as "leave the live state alone".
|
||||
So restoring to an old Save Point moves the transcript back without moving the
|
||||
state:
|
||||
|
||||
```
|
||||
restore to the pre-M5 Save Point at depth 2 -> head (1,2)
|
||||
state left standing describes depth 6 (the M5 turn's entities)
|
||||
```
|
||||
|
||||
The reader is at depth 2 and the state panel describes depth 6. §9 of the M5
|
||||
brief forbids exactly this.
|
||||
|
||||
## M. Performance
|
||||
|
||||
One regression, in the action-list read path.
|
||||
`paging.py:ACTION_LIST_COLUMNS` does not include `models.Action.state_changes`,
|
||||
so every row lazy-loads it individually:
|
||||
|
||||
```
|
||||
11 actions on the page -> 24 queries, 13 fetching state_changes one row at a time
|
||||
51 actions on the page -> 64 queries, 53 fetching state_changes one row at a time
|
||||
```
|
||||
|
||||
The comment directly above that list warns against this exact mistake for
|
||||
`world_delta`, in these words: *"Omitting it saves no bytes. It converts one
|
||||
bulk read into one lazy load per row."* The new column was added to the model
|
||||
without being added to the list.
|
||||
|
||||
## N. Frontend
|
||||
|
||||
`npm run lint` exits 0. Every warning it prints is pre-existing at `62a997f` in
|
||||
files M5 did not touch. `npm run build` succeeds. `docker build` succeeds and
|
||||
the image contains `backend/app/narrative/`; M5 added no dependency and needed
|
||||
no packaging change.
|
||||
|
||||
The stale-panel bug reported during implementation was fixed in `saveEdit` by
|
||||
bumping `stateKey`. That is the correct fix for that mutation, and the panel's
|
||||
key (`actions.length` + `stateKey`) does now cover every state-moving mutation:
|
||||
undo/redo, take switching, save-point restore, correction, delete. It is
|
||||
per-mutation rather than a single shared invalidation boundary, so a future
|
||||
mutation must remember to bump it — worth noting, but not a defect today.
|
||||
|
||||
## O. Real-browser verification
|
||||
|
||||
Firefox 154.0.1, headless, real backend, fresh database. **16 of 16 checks
|
||||
passed**: inspector starts empty; shows what a turn established; groups by
|
||||
category; shows possession with its owner; follows a later turn, Undo, Redo, and
|
||||
a Save Point restore; a manual correction lands, is marked as the reader's own,
|
||||
survives another turn, and is explained in the audit view; a narrator edit
|
||||
re-derives state; edited prose carries no protocol; no console errors.
|
||||
|
||||
That suite edits the **last** narrator turn, so it does not exercise the case in
|
||||
Finding 1. A second browser probe was written for that case, and it reproduces
|
||||
the defect visibly — see below.
|
||||
|
||||
## P. Realistic-model behaviour
|
||||
|
||||
Run against one small local model (`qwen2.5:3b-instruct`) on the trusted LAN. All
|
||||
3 otherwise-skipped tests pass. Over 6 turns:
|
||||
|
||||
```
|
||||
accepted 3 | partially_accepted 1 | unparseable 2
|
||||
events rejected 1 (unknown_reference)
|
||||
```
|
||||
|
||||
Two of six turns produced a state block the pipeline could not parse. The
|
||||
pipeline degrades correctly — the prose still lands, the state is left alone,
|
||||
and the proposal is recorded `unparseable` — which is why the tests pass. But a
|
||||
33% unparseable rate on a 3B model is a real datum for the prompt work ahead.
|
||||
|
||||
This is **one** model. It is not evidence about models in general, and no
|
||||
broader claim is made from it.
|
||||
|
||||
## Q. Test suite
|
||||
|
||||
```
|
||||
764 passed, 3 skipped, 1 warning (197s)
|
||||
```
|
||||
|
||||
All three skips are `test_narrative_realistic.py`, skipped for missing
|
||||
`AIDND_TEST_ENDPOINT` / `AIDND_TEST_MODEL`. With those set, all three pass (§P).
|
||||
There are no other skips and no xfails.
|
||||
|
||||
Rollback assertions were **moved, not deleted**. `test_worldstate_integration.py`
|
||||
went from 6 tests to 4 and was rewritten as a demotion test, with the removed
|
||||
tests and the reason for each recorded in the module docstring. Their subject
|
||||
matter is now covered by `test_narrative_state.py` (67 tests).
|
||||
|
||||
Two gaps:
|
||||
|
||||
- `test_state_revert.py` — the dedicated unit test for
|
||||
`attempts.restore_state` / `snapshot_outcome` — has **zero** references to
|
||||
`narrative_state`. It still tests only the legacy column. The NULL-snapshot
|
||||
rule that produces the migration defect in §L lives in exactly the code this
|
||||
file covers, and was not extended to the column M5 made load-bearing.
|
||||
- No test covers editing a turn that has later **visible** story. That is the
|
||||
hole Finding 1 sits in.
|
||||
|
||||
## R. Genre neutrality (J01–J03)
|
||||
|
||||
The backend is neutral. The new package is clean, and a science-fiction campaign
|
||||
plays through the same events with no fantasy vocabulary anywhere in the path.
|
||||
Remaining hits in the backend are either citations of AI Dungeon as the design
|
||||
being followed, the frozen wire-format string `ai-dnd-adventure-v2` (which must
|
||||
not change), or the demoted legacy world-state module's own examples.
|
||||
|
||||
The frontend is not. `frontend/src/pages/ScenarioEditor.jsx` is still routed at
|
||||
`scenarios/:id` and still tells the user, in user-facing prose, that the app
|
||||
"will track them each turn — HP, mana, a raised alarm, quest objectives", with a
|
||||
JSON placeholder of `player.hp`, `npcs.gwen.stats.trust`, and `milestones`. That
|
||||
is the surface ADR 010 replaced, still describing the replaced design to the
|
||||
user. J03 asks that genre be configuration; this screen hard-codes one genre's
|
||||
vocabulary as the explanation of how state works.
|
||||
|
||||
## S. Findings
|
||||
|
||||
### Finding 1 — Editing a turn with later visible story breaks the state/head invariant — **SERIOUS**
|
||||
|
||||
`STORY-BRANCH-SEMANTICS.md` §14A refuses an in-place edit only when descending
|
||||
story is **off screen**. An edit with a *visible* future is therefore permitted,
|
||||
and `_reevaluate_state()` in `routers/adventures/actions.py` then calls
|
||||
`narrative.store.set_current()` with the edited node's re-derived state — while
|
||||
the head stays at the tip.
|
||||
|
||||
The comment there asserts the case cannot arise: *"If the head sits further
|
||||
along, the refusal above already ran — this node has no off-screen future."*
|
||||
That is incorrect. The refusal covers off-screen futures only.
|
||||
|
||||
Reproduced in a real browser, on a four-turn campaign, by editing the first
|
||||
narrator turn through the UI's own ✎ control:
|
||||
|
||||
```
|
||||
state panel BEFORE : CHARACTERS Aldric, Mara | LOCATIONS the Crooked Lantern
|
||||
ITEMS the silver key | POSSESSIONS held by Mara
|
||||
FACTS Mara knows where the key was found
|
||||
OPEN STORY THREADS Reach the Old Abbey
|
||||
state panel AFTER : "Nothing established yet."
|
||||
transcript : all four turns still on screen
|
||||
head : still (1,7), the tip
|
||||
```
|
||||
|
||||
The database shows the invariant broken directly — the head's own snapshot is
|
||||
intact while the live column it is supposed to agree with is empty:
|
||||
|
||||
```
|
||||
live narrative_state entities 0 facts 0 threads 0
|
||||
snapshot at depth 7 (head) entities 4 facts 1 threads 1
|
||||
snapshots at depths 2..6 entities 4 ← stale: describe prose that no longer exists
|
||||
```
|
||||
|
||||
Three consequences: the reader sees a full transcript over an empty state panel;
|
||||
the next turn's prompt is built from the rewound state; and Undo/Redo restores
|
||||
the stale downstream snapshots, resurrecting state whose originating prose was
|
||||
edited away. An earlier API-level probe showed the milder form of the same bug —
|
||||
after editing a "red cloak" turn to "green", undoing back restored `cloak: red`
|
||||
while the visible prose said green, the prose/state disagreement §15 forbids.
|
||||
|
||||
**D10 is not satisfied.** Its M5 pass condition is *"downstream state is
|
||||
re-evaluated"*. Downstream state is not re-evaluated; live state is rewound and
|
||||
downstream snapshots are left stale. Separately, §15 lists five requirements for
|
||||
this workflow and only 1–3 are implemented: there is no new continuation and the
|
||||
original narration is overwritten in place, so *"old version/future remains
|
||||
retained"* also fails. The UI offers ✎ (edits in place, no fork) and ⑂ (forks,
|
||||
but regenerates rather than using the corrected text) — **no available path
|
||||
satisfies §15 fully.**
|
||||
|
||||
### Finding 2 — N+1 on the action list — **MODERATE**
|
||||
|
||||
`state_changes` is missing from `ACTION_LIST_COLUMNS`; 51 rows cost 53 extra
|
||||
queries. Details and the warning comment that predicted it: §M.
|
||||
|
||||
### Finding 3 — Restoring to a pre-M5 node leaves M5 state standing — **MODERATE**
|
||||
|
||||
NULL snapshot is treated as "leave the live state alone", so head and state
|
||||
disagree at any migrated position. Details: §L. Same broken invariant as
|
||||
Finding 1, different cause.
|
||||
|
||||
### Finding 4 — A withdrawn fact survives in the prompt's history replay — **MODERATE**
|
||||
|
||||
The state section drops it, the history replay keeps it verbatim, and nothing
|
||||
says it was corrected. Details: §I, §J.
|
||||
|
||||
### Finding 5 — Export/import loses the audit trail — **MINOR**
|
||||
|
||||
`state_events` and `state_proposals` are not bundled; an imported campaign shows
|
||||
0 audit events. Effects survive, records do not. Details: §K.
|
||||
|
||||
### Finding 6 — Extraction strips legitimate prose — **MINOR**
|
||||
|
||||
The fence stripper removes content it should leave alone:
|
||||
|
||||
```
|
||||
CUT a bracketed aside that merely mentions the state block
|
||||
CUT a story containing a legitimate ```json fence
|
||||
in : 'She typed it out:\n```json\n{"name": "Mara"}\n```\nThen closed the terminal.'
|
||||
out: 'She typed it out:\n\nThen closed the terminal.'
|
||||
KEPT a story containing a ```python fence
|
||||
```
|
||||
|
||||
A story about programmers loses its code. The `python` fence surviving shows the
|
||||
rule is specifically over-broad on `json`.
|
||||
|
||||
### Finding 7 — The RPG schema editor still describes the replaced design — **MINOR**
|
||||
|
||||
Details: §R.
|
||||
|
||||
### Finding 8 — Snapshot/restore unit tests were not extended to the new column — **MINOR**
|
||||
|
||||
Details: §Q.
|
||||
|
||||
## T. Acceptance criteria
|
||||
|
||||
| ID | Criterion | Result | Basis |
|
||||
|----|-----------|--------|-------|
|
||||
| C01 | Campaign canon preserved | PASS | canon survives turns, restore, and round-trip |
|
||||
| C02 | Possession state | PASS | `set_possession` moves ownership; shown with owner in UI |
|
||||
| C03 | Character knowledge not invented | PASS | `knows()` gated on recorded facts; unknown refs rejected |
|
||||
| C04 | Manual state correction | **PARTIAL** | reflected in state and auditable live; but the withdrawn fact survives in the history replay (F4) and the audit is lost on import (F5) |
|
||||
| C06 | Structured state matches accepted narration | PASS | absolute events only, no delta/absolute ambiguity; malformed proposals rejected, recorded, prose still lands |
|
||||
| D01 | Undo one turn | PASS | snapshot restore, verified in browser and API |
|
||||
| D04 | Redo | PASS | as above |
|
||||
| D05 | Redo invalidated by new continuation | PASS | abandoned line isolated (E01/E04 probes) |
|
||||
| D06 | Retry narrator response | PASS | unchanged from M3/M4, re-run green |
|
||||
| D07 | Select prior retry take | PASS | take switch restores that take's state |
|
||||
| D08 | Retry does not delete prior take | PASS | suite + browser |
|
||||
| D09 | Edit earlier user input | PASS | within the same mechanism as D10; no visible-future case in the fixtures |
|
||||
| D10 | Edit narrator output | **FAIL** | M5's own pass condition — downstream state re-evaluation — is not met; state is rewound, downstream snapshots left stale (F1); retention condition also unmet |
|
||||
| D11 | Named checkpoint | PASS | M4 machinery, re-verified in browser |
|
||||
| D12 | Restore checkpoint | PASS | state moves with the restore |
|
||||
| D13 | Restore does not delete later history | PASS | later rows retained and reachable |
|
||||
| D14 | Delete checkpoint | PASS | suite |
|
||||
| E01 | Abandoned future cannot affect active state | PASS | probe: no leak into state or prompt |
|
||||
| E04 | Scene state is lineage-safe | PASS | same probe |
|
||||
| H05 | Invalid state event rejected | PASS | hostile payloads produced no filesystem effect and no accepted events |
|
||||
| I01 | Export campaign | PASS | bundle carries state, canon, checkpoints, head |
|
||||
| I02 | Import exported campaign | PASS | 201, state and authority intact |
|
||||
| I03 | Branch/disposable history export | PASS | snapshots 7 of 7 |
|
||||
| I04 | Checkpoint export | PASS | save points round-trip |
|
||||
| I07 | Export/import preserves an undone active head | PASS | `can_redo` preserved in the copy |
|
||||
| J01 | Science-fiction campaign | PASS | plays through the same events, no genre coupling |
|
||||
| J02 | Generic entity support | PASS | `entity_type` is free-form |
|
||||
| J03 | Genre profiles are configuration | **PARTIAL** | backend neutral; the scenario editor still hard-codes HP/mana as the explanation of state (F7) |
|
||||
| L01 | Atomic turn commit | PASS | induced storage failure, not mocks — §H |
|
||||
| L02 | State reconstruction | PASS for undo/redo; breaks after an edit (F1) |
|
||||
| L03 | Checkpoint reconstruction after restart | PASS | M4's real-process-boundary test reads the M5 state document |
|
||||
|
||||
## U. Documentation
|
||||
|
||||
`planning/` was updated in step with the code, and `DATA-MODEL.md` §17 correctly
|
||||
describes the hybrid that was actually built. Two corrections are needed:
|
||||
|
||||
- `STORY-BRANCH-SEMANTICS.md` §14A should no longer say the refusal is "replaced
|
||||
by" M5's re-evaluation. It was not replaced; it was retained for off-screen
|
||||
futures, and the visible-future case it does not cover is unhandled.
|
||||
- The incorrect comment in `_reevaluate_state()` quoted in Finding 1 should be
|
||||
removed rather than reworded — it asserts an invariant the code does not hold.
|
||||
|
||||
## V. Planning-document recommendations
|
||||
|
||||
1. **The state-document shape deserves ratification.** ADR 010 settled the
|
||||
event protocol; it did not settle the document those events write into
|
||||
(`entities` / `facts` / `possessions` / `threads` / `scene`, each with
|
||||
authority and provenance). That shape is now load-bearing for the prompt, the
|
||||
UI, export, and migration. It should be written down as an ADR rather than
|
||||
left as an implementation detail of `narrative/model.py`.
|
||||
2. **§15 of the M5 brief needs re-specifying before it can be implemented.** It
|
||||
requires a corrected narrator turn to become authoritative *and* the original
|
||||
to be retained. Retaining the original means forking; the current ✎ does not
|
||||
fork and the current ⑂ does not use the typed text. The missing operation —
|
||||
"fork here, using this exact text, and re-derive forward" — should be
|
||||
specified explicitly.
|
||||
3. **D10 should record its remaining scope**, the way it already records its M3
|
||||
and M5 split, rather than being left looking complete.
|
||||
|
||||
## W. M6 readiness
|
||||
|
||||
M5's core is sound: the protocol is unambiguous, the security boundary holds,
|
||||
restore is snapshot-based and does not replay, lineage isolation is intact, and
|
||||
atomicity survives an induced storage failure. That is the hard part, and it
|
||||
works.
|
||||
|
||||
M6 should not start on top of Finding 1. The broken invariant is
|
||||
`transcript position == head == authoritative state`, and every later feature
|
||||
that reads state at a position inherits it. Findings 2, 3, and 4 are each
|
||||
contained enough to fix alongside it. Findings 5–8 can ride along or be filed.
|
||||
|
||||
Recommended sequence: fix Findings 1 and 3 together, since both are the same
|
||||
invariant reached by different routes; then 2 and 4; then close out M5.
|
||||
|
||||
---
|
||||
|
||||
## Evidence appendix
|
||||
|
||||
| Check | Command / harness | Result |
|
||||
|---|---|---|
|
||||
| Backend suite | `.venv/bin/python -m pytest tests/ -q` | 764 passed, 3 skipped |
|
||||
| Realistic model | same, with endpoint and model set | 3 passed (674s) |
|
||||
| Frontend lint | `npm run lint` | exit 0, warnings pre-existing |
|
||||
| Frontend build | `npm run build` | success |
|
||||
| Container | `docker build` | success; image contains `app/narrative/` |
|
||||
| Browser | Firefox 154.0.1 headless over WebDriver | 16/16 |
|
||||
| Browser (edit case) | second probe, first narrator turn | defect reproduced |
|
||||
| Atomicity | `state_events` renamed away mid-turn | no half-commit |
|
||||
| No-replay | `state_events` renamed away, then undo/redo | both 200 |
|
||||
| Hostile events | 5 payloads through the real turn path | 0 accepted, no filesystem effect |
|
||||
| N+1 | query counter over the action list | 51 rows -> 53 extra queries |
|
||||
| Migration | pre-M5 DB rebuilt, stamped, migrated | restore leaves state stale |
|
||||
| Export/import | real bundle round-trip | state yes, audit no |
|
||||
|
||||
---
|
||||
---
|
||||
|
||||
# ADDENDUM — M5 Corrective Pass
|
||||
|
||||
**Date:** 2026-09-04
|
||||
**Branch:** `m5-narrative-state` **Base commit:** `62a997f`
|
||||
**Status of the review above:** unchanged. Nothing in it has been edited or
|
||||
withdrawn. This addendum records what was done about it.
|
||||
|
||||
**Recommendation:** M5 CORRECTED — READY FOR CLOSEOUT / M6
|
||||
|
||||
## A. Repository and staging state
|
||||
|
||||
Recorded before any change was made:
|
||||
|
||||
```
|
||||
branch m5-narrative-state
|
||||
HEAD 62a997f364e5387e4cc1dcc20f5a6618dee5f267
|
||||
staged 45 files changed, 4979 insertions(+), 364 deletions(-)
|
||||
12 added, 33 modified, nothing unstaged, nothing untracked
|
||||
```
|
||||
|
||||
The staged M5 implementation was never reset, discarded, squashed or recreated.
|
||||
The corrective work is added on top of it, and the whole is now staged together:
|
||||
|
||||
```
|
||||
staged 57 files changed, 6809 insertions(+), 487 deletions(-)
|
||||
15 added, 41 modified, 1 renamed (the M4 report, into planning/archive)
|
||||
nothing unstaged, nothing untracked
|
||||
```
|
||||
|
||||
LICENSE is untouched, the AI-DnD base commit `d72f7c1b` is still an ancestor of
|
||||
HEAD, and no database, secret, token or browser profile is staged. No commit was
|
||||
created: this repository's commits are signed by its owner, so the message is
|
||||
prepared at `.git/M5_CORRECTIVE_MSG` instead (§P).
|
||||
|
||||
## B. Disposition of Findings 1–8
|
||||
|
||||
| # | Finding | Disposition |
|
||||
|---|---------|-------------|
|
||||
| 1 | Narrator edit breaks the state/head invariant (**serious**) | **Fixed.** Narrator editing rebuilt on §§14-15 fork semantics. §C, §D, §E. |
|
||||
| 2 | N+1 on the action list | **Fixed.** `state_changes` added to the bulk read; 51 rows now cost a constant query count. §G. |
|
||||
| 3 | Restoring to a pre-M5 node leaves M5 state standing | **Fixed.** Migration 88 backfills; a missing snapshot restores the empty document. §F. |
|
||||
| 4 | A withdrawn fact survives in the prompt's history replay | **Fixed.** History carries prose only; withdrawals are named explicitly. §H. |
|
||||
| 5 | Export/import loses the audit trail | **Deferred to M9**, deliberately, and recorded in C04 and BUILD-MILESTONES. §K. |
|
||||
| 6 | Extraction strips legitimate prose | **Fixed**, and a second defect found while fixing it. §I. |
|
||||
| 7 | Scenario editor describes the replaced design | **Copy corrected; screen deferred to M8.** §K. |
|
||||
| 8 | Snapshot/restore tests never covered the new column | **Fixed.** `test_state_revert.py` now covers it first-class. §J. |
|
||||
|
||||
## C. The narrator-edit implementation
|
||||
|
||||
The operation is no longer a write to the row being corrected. Nothing on the
|
||||
line being left is written to at all.
|
||||
|
||||
```text
|
||||
before after
|
||||
|
||||
parent parent
|
||||
└── original narrator ├── original narrator ──> old future [retained]
|
||||
└── old future └── corrected narrator [active]
|
||||
```
|
||||
|
||||
`_edit_narration` in `backend/app/routers/adventures/actions.py` maps one step
|
||||
to each clause of §15:
|
||||
|
||||
1. **Return to the state before the narration** — the preceding node's
|
||||
`narrative_state_after`, one row read, not a replay.
|
||||
2. **Treat the edited text as the accepted output** — stored verbatim with only
|
||||
the protocol block stripped. No model is called.
|
||||
3. **Re-evaluate the implied state** — the same extraction, allowlist, schema,
|
||||
reference and canon validation a generated turn faces.
|
||||
4. **Create a new active continuation** — a new node, and the head on it.
|
||||
5. **Retain the original narration and its future** — untouched.
|
||||
|
||||
Two shapes, chosen by whether anything was written after the turn:
|
||||
|
||||
- **At the tip:** the turn's attempts are still leaves, so the correction joins
|
||||
them as another take (`attempts.hand_over_the_prompt` + `attempts.add_attempt`)
|
||||
and the original stays beside it in the pager. No branch, and the head does
|
||||
not move.
|
||||
- **With story below it**, visible or not: `lineage.branch_of` →
|
||||
`tree.branch_at(depth - 1)` → `head.mark_superseded` → `tree.place_action`.
|
||||
The departed line keeps its node, its future and its live flag.
|
||||
|
||||
**No new history mechanism was introduced.** This is the ⑂ path already in
|
||||
`takes.py` with the reader's text in place of a generated reply, using the same
|
||||
`tree`, `head`, `attempts` and `lineage` primitives M3 and M4 provide.
|
||||
|
||||
Two consequences worth stating:
|
||||
|
||||
- The §14A refusal is **gone for narrator turns** — the case it refused is now
|
||||
handled rather than blocked, because a fork writes nothing to the off-screen
|
||||
line. It remains for a player's own input (§13), which M5 did not change.
|
||||
- Editing a take the story is **not** telling stays a plain in-place edit. Such
|
||||
a take has no continuation of its own, so correcting its words contradicts
|
||||
nothing. This preserved two existing behaviours the first draft of the fix had
|
||||
broken.
|
||||
|
||||
The frontend follows: `saveEdit` detects that the server answered with a
|
||||
different node and re-reads the window, because the shape of the story changed
|
||||
and only the server can say what the transcript is now.
|
||||
|
||||
## D. How retention was proven
|
||||
|
||||
Not by inspection — by identity. Every test below captures the original row's id
|
||||
and text and the ids of every row below it *before* the edit, and asserts they
|
||||
are all still present and unchanged afterwards.
|
||||
|
||||
- `test_editing_a_narrator_turn_with_visible_descendants_forks`
|
||||
- `test_the_old_narration_and_its_future_leave_the_active_transcript`
|
||||
- `test_editing_a_narrator_turn_with_an_undone_future_keeps_it`
|
||||
- `test_editing_a_narrator_turn_a_divergence_left_behind_keeps_that_line`
|
||||
- `test_an_edit_is_safe_when_a_future_is_off_screen`
|
||||
- `test_editing_the_latest_narrator_turn_still_works` (the original take retained
|
||||
and no longer live, with no branch created)
|
||||
|
||||
In the browser, the same thing read straight out of SQLite: the original
|
||||
narrator row still holds its own words, every row of the old future still
|
||||
exists, and the correction is a different node id on a different branch id.
|
||||
|
||||
## E. How live-state/head consistency was proven
|
||||
|
||||
A helper asserts the invariant directly against the database rather than through
|
||||
the API, resolving the head node **through the lineage** — after a fork the head
|
||||
branch owns one node and inherits the rest of the path:
|
||||
|
||||
```python
|
||||
node = head.node_at(db, adventure, adventure.head_depth)
|
||||
return normalize(adventure.narrative_state) == normalize(node.narrative_state_after)
|
||||
```
|
||||
|
||||
It is asserted after every narrator edit, and at **every position visited** while
|
||||
undoing and redoing across an edit
|
||||
(`test_undo_and_redo_after_an_edit_stay_on_the_corrected_lineage`), which is
|
||||
where the review found the worst symptom — Undo restoring a snapshot from a line
|
||||
the reader was no longer on, so the prose said green and the state said red.
|
||||
That test additionally asserts that no attribute from the abandoned line ever
|
||||
reappears.
|
||||
|
||||
The same equality is checked in the browser run (check 8) and across Undo/Redo
|
||||
there (check 9).
|
||||
|
||||
## F. How pre-M5 positions are handled
|
||||
|
||||
Two halves, so that the stored data is explicit rather than relying on a
|
||||
fallback:
|
||||
|
||||
- **Migration 88** — no DDL, a data pass. `_backfill_narrative_snapshots` writes
|
||||
the empty narrative document onto every action whose `narrative_state_after`
|
||||
is NULL. One statement, no row loop: the document is identical for every row,
|
||||
so it is encoded once with the same `compression.pack` and `narrative.model`
|
||||
the runtime uses, and bound as a single parameter.
|
||||
- **`attempts.restore_state`** — a missing narrative snapshot now restores the
|
||||
empty document. This covers a node arriving from an older export, which the
|
||||
migration never sees.
|
||||
|
||||
The legacy RPG column deliberately keeps the opposite rule: a NULL
|
||||
`world_state_after` is still left alone, because nothing consults those numbers
|
||||
and blanking a running campaign's would help no one. The difference is now
|
||||
documented in the function rather than implicit.
|
||||
|
||||
The empty document is the honest answer. A pre-M5 position established nothing
|
||||
in the narrative-state system because that system did not exist yet; retaining
|
||||
another position's state is a claim about a story that had not been told.
|
||||
|
||||
`backend/tests/test_pre_m5_compatibility.py` builds a genuine pre-M5 database
|
||||
(M5 tables dropped, columns removed, `user_version` rewound to 80), migrates it,
|
||||
and runs the six required steps:
|
||||
|
||||
```
|
||||
the migration backfills every existing action PASS
|
||||
the migrated campaign opens and keeps its history PASS
|
||||
restoring a pre-M5 Save Point leaves no later state standing PASS
|
||||
undo/redo across the pre-M5 boundary stay coherent PASS
|
||||
a pre-M5 campaign can be continued normally PASS
|
||||
```
|
||||
|
||||
Restoring the old Save Point now yields `head depth 2` with an empty document
|
||||
and `live state == head snapshot`; redoing forward brings the M5 state back with
|
||||
the position it belongs to. State restoration remains a bounded row read — no
|
||||
head movement was turned into replay-from-root.
|
||||
|
||||
## G. Action-list query counts
|
||||
|
||||
Measured with a statement counter over the real endpoint, before and after:
|
||||
|
||||
| page | before | after |
|
||||
|------|--------|-------|
|
||||
| 11 actions | 24 queries, 13 fetching `state_changes` one row at a time | **13 queries** |
|
||||
| 51 actions | 64 queries, 53 fetching `state_changes` one row at a time | **13 queries** |
|
||||
|
||||
Constant with page size. The fix is one line: `models.Action.state_changes`
|
||||
joins `ACTION_LIST_COLUMNS`, which is what `models.py` already said it was for
|
||||
("`world_delta` has an M5 counterpart in `state_changes` for the bulk read").
|
||||
It is a small per-turn column of the same order as `world_delta`, not the
|
||||
deferred snapshot — no large unrelated column was pulled in, and
|
||||
`narrative_state_after` is still asserted *not* to be fetched in bulk.
|
||||
|
||||
`test_the_action_list_does_not_cost_a_query_per_action` asserts by measurement
|
||||
rather than by inspecting the column tuple, so a future column consumed during
|
||||
serialization is caught the same way. It was confirmed to fail without the fix
|
||||
(58 SELECTs for 52 rows) and pass with it.
|
||||
|
||||
## H. Manual corrections and the narrator's prompt
|
||||
|
||||
Two changes, neither of which touches the stored historical records:
|
||||
|
||||
- **Replayed history is prose only.** `_history_text` no longer reconstructs the
|
||||
protocol block into past turns. That reconstruction put a second, older
|
||||
account of the world into the same prompt as the authoritative one with
|
||||
nothing marking which governed, and handed back a fact the reader had
|
||||
explicitly withdrawn as an accepted event. The format instruction survives in
|
||||
`EMIT_RULE` (with a worked example) and `EMIT_REMINDER` (placed last).
|
||||
- **A withdrawn fact is named.** `render.for_prompt` adds a section after the
|
||||
facts that stand:
|
||||
|
||||
```text
|
||||
No longer true — do not treat these as established:
|
||||
Mara knows where the key was found — Mara never learned where the silver key was found.
|
||||
```
|
||||
|
||||
Silence was the problem: dropping the fact left the narration that first
|
||||
asserted it as the only account in the prompt, and prose reads as current
|
||||
truth.
|
||||
|
||||
Evidence, from the review's own Mara example run through the real turn pipeline:
|
||||
|
||||
```
|
||||
history section contains "```state": no
|
||||
contains "add_fact": no
|
||||
contains "mara-knows": no
|
||||
state section under "Established": the withdrawn fact is absent
|
||||
under "No longer true": named, with the reader's reason
|
||||
```
|
||||
|
||||
Nothing was deleted or rewritten: the invalidated fact keeps its status, reason
|
||||
and provenance in the document, and the `state_events` audit is untouched.
|
||||
|
||||
## I. Fence-stripping behaviour
|
||||
|
||||
The extractor now removes the application's own protocol payload and nothing
|
||||
else. `state` is our label and is taken unconditionally; `json` and unlabelled
|
||||
fences are taken only when their contents are this protocol — judged both by
|
||||
parsing into a proposal *and* by plainly reading as one.
|
||||
|
||||
| input | result |
|
||||
|---|---|
|
||||
| ```` ```state ```` block | stripped |
|
||||
| ```` ```state ```` block that does not parse | stripped, raw kept for the audit |
|
||||
| ```` ```json ```` containing `{"name": "Mara"}` | **kept** — it is the story |
|
||||
| ```` ```json ```` containing a real proposal | stripped |
|
||||
| ```` ```json ```` containing a *malformed* proposal | stripped |
|
||||
| ```` ```python ```` block | kept |
|
||||
| unlabelled fence with non-proposal JSON | kept |
|
||||
| dangling ```` ```json ```` that is story | kept |
|
||||
| dangling ```` ```json ```` that is a truncated proposal | stripped |
|
||||
| trailing bracketed aside mentioning "state block" | kept |
|
||||
| the prompt's own reminder, parroted back | stripped |
|
||||
|
||||
The malformed-proposal row is a defect the corrective pass introduced and then
|
||||
caught: narrowing the rule to "must parse" meant a small model that mangled its
|
||||
own JSON had the wreckage shown to the reader. **The realistic-model run found
|
||||
it, not the unit tests** — a reminder of why that run exists. The rule became
|
||||
"parses as a proposal, or plainly reads as protocol", and
|
||||
`test_a_malformed_proposal_in_a_json_fence_never_reaches_the_reader` pins it.
|
||||
|
||||
## J. Test results
|
||||
|
||||
```
|
||||
794 passed, 3 skipped, 1 warning (backend, 206s)
|
||||
```
|
||||
|
||||
Up from 764 passed at review time — 30 net new tests. The 3 skips are the
|
||||
realistic-model tests, skipped for missing `AIDND_TEST_ENDPOINT` /
|
||||
`AIDND_TEST_MODEL`; with those set they run (§L).
|
||||
|
||||
New and rewritten:
|
||||
|
||||
- `test_narrative_state.py` — seven D10 regressions, two correction-to-prompt
|
||||
regressions, nine fence-safety tests.
|
||||
- `test_pre_m5_compatibility.py` — new module, five migration regressions.
|
||||
- `test_state_revert.py` — seven new tests covering `narrative_state`
|
||||
first-class (Finding 8): the snapshot is recorded, deep-copied both ways, and
|
||||
written empty when there is none; restore puts it back, does not alias it, and
|
||||
restores the empty document when the node has no snapshot, while the legacy
|
||||
column keeps the opposite rule.
|
||||
- `test_egress.py` — the query-count regression and a guard that
|
||||
`narrative_state_after` stays out of the bulk read.
|
||||
- `test_head_cursor.py`, `test_change_visibility.py`, `test_retry_variants.py` —
|
||||
four tests rewritten. Each asserted behaviour this pass deliberately replaces
|
||||
(the §14A refusal for narrator turns; the protocol replay; edit-as-rewrite).
|
||||
None was deleted: each now asserts the stronger property that replaced it.
|
||||
|
||||
Targeted runs, all green: narrator edit with visible descendants; latest-turn
|
||||
edit; retained original future; live-state == head-snapshot; pre-M5 Save Point
|
||||
restore; Undo/Redo across old and new positions; manual correction → next
|
||||
prompt; fence stripping; action-list query count; atomic state commit; lineage
|
||||
isolation.
|
||||
|
||||
## K. Deferred, deliberately
|
||||
|
||||
- **M9 — export/recovery (Finding 5).** `state_events` / `state_proposals` are
|
||||
not carried in a bundle, so an imported campaign keeps a correction's effect
|
||||
(`manual_correction` authority survives) but reports zero audit events. C04
|
||||
therefore passes for a live campaign and not across a round trip; this is
|
||||
written into C04's result and into BUILD-MILESTONES rather than left implicit.
|
||||
Not fixed here: bundle format work is M9's, and the corrective gate did not
|
||||
require it.
|
||||
- **M8 — browser UX (Finding 7).** The scenario editor still exposes the legacy
|
||||
RPG stat schema. Its copy claimed the app "will track them each turn — HP,
|
||||
mana, a raised alarm, quest objectives", which M5 made false; that sentence is
|
||||
corrected to say what is now true. The screen itself is M8's to redesign.
|
||||
- **§13 — editing player input.** Still an in-place edit guarded by the
|
||||
off-screen refusal. Bringing it onto the §§14-15 footing was outside M5's
|
||||
scope; recorded in §14A and BUILD-MILESTONES.
|
||||
- **A player's own input may still contain a protocol fence**, since extraction
|
||||
applies to narrator output. Observed while building the browser harness, not a
|
||||
review finding, and not acted on here.
|
||||
|
||||
## L. Realistic-model results
|
||||
|
||||
Run against the same single small local model the review used, on the trusted
|
||||
LAN. All 3 otherwise-skipped tests pass (805s).
|
||||
|
||||
```
|
||||
review run corrective run
|
||||
turns 6 6
|
||||
accepted 3 6
|
||||
partially_accepted 1 0
|
||||
unparseable 2 0
|
||||
events rejected 1 (unknown_ref) 0
|
||||
assembled prompt 9382 chars 9922 chars
|
||||
```
|
||||
|
||||
**This is one stochastic run of six turns against one 3B model, and no causal
|
||||
claim is made from it.** The prompt did change materially — replayed history no
|
||||
longer carries protocol blocks, and withdrawn facts are now stated — so an
|
||||
effect is plausible in either direction, but six turns cannot separate that from
|
||||
run-to-run variance. What the run does establish is that the corrective changes
|
||||
did not degrade emission against a real model, and that the pipeline still
|
||||
degrades safely: no event was rejected and no block was unparseable.
|
||||
|
||||
The run also earned its keep by catching a real defect the unit tests missed —
|
||||
the malformed-proposal leak described in §I.
|
||||
|
||||
## M. Frontend, container
|
||||
|
||||
```
|
||||
npm run lint exit 0 (7 warnings, all pre-existing at 62a997f, in files M5 did not touch)
|
||||
npm run build success
|
||||
docker build success
|
||||
```
|
||||
|
||||
## N. Real-browser results
|
||||
|
||||
Firefox 154.0.1, headless, over WebDriver, against the real built frontend
|
||||
served by the real backend on a fresh database.
|
||||
|
||||
**The standard M5 checks: 16/16 pass** — inspector empty then populated, grouped
|
||||
by category, possession with owner, a later turn moving it, Undo, Redo, Save
|
||||
Point restore, a manual correction landing and marked as the reader's own and
|
||||
surviving another turn, the audit view, a narrator edit re-deriving state, no
|
||||
protocol in the prose, no console errors.
|
||||
|
||||
**The previously failing case: 16/16 pass.** Four turns establishing state, then
|
||||
the *earliest* narrator turn corrected through the UI's own ✎ control with all
|
||||
later story on screen — the exact case the review reproduced as broken:
|
||||
|
||||
```
|
||||
1-2 several turns established several facts PASS
|
||||
3 an Edit control on the earliest narrator turn PASS
|
||||
5 the corrected prose is what the story now tells PASS
|
||||
5b the original narration is off the active transcript PASS
|
||||
5c the old future is off the active transcript too PASS
|
||||
6 the original narrator row still exists, with its own words PASS
|
||||
6b every row of the old future still exists PASS
|
||||
6c the correction is a different node on a different branch PASS
|
||||
7 the panel shows what the corrected turn established PASS
|
||||
7b nothing from the abandoned future is still shown PASS
|
||||
8 live narrative_state equals the active-head snapshot PASS
|
||||
9 Undo and Redo never resurrect the abandoned line's state PASS
|
||||
10 the next prompt carries the corrected narration PASS
|
||||
10b and not the abandoned future PASS
|
||||
10c and no protocol block in the replayed history PASS
|
||||
11 no console errors PASS
|
||||
```
|
||||
|
||||
No console errors in either run.
|
||||
|
||||
One harness correction worth recording, because the first run's failures were
|
||||
mine and not the product's: the probe initially picked the first Edit control on
|
||||
the page, which belongs to the opening *player* action. Editing a player turn
|
||||
correctly takes the unchanged in-place path, so nothing forked. Addressing the
|
||||
earliest **narrator** row — `.action:not(.player)` carrying the expected words —
|
||||
was the fix, and every check then passed.
|
||||
|
||||
## O. Acceptance status
|
||||
|
||||
| ID | Before | After | Basis |
|
||||
|----|--------|-------|-------|
|
||||
| **D10** | FAIL | **PASS** | All three pass conditions demonstrated: corrected narration authoritative, state re-evaluated, old version and future retained. §C, §D, §E, §N. |
|
||||
| **C04** | PARTIAL | **PASS for the live campaign** | The withdrawn fact no longer returns through history and the withdrawal is stated explicitly. Auditability across export/import remains open and is recorded as M9 debt. §H, §K. |
|
||||
| **L02** | PASS for undo/redo; broke after an edit | **PASS** | The invariant is asserted at every position visited while undoing and redoing across an edit, and after restoring to a migrated pre-M5 position. §E, §F. |
|
||||
| **J03** | PARTIAL | **PARTIAL, narrowed** | Backend unchanged and neutral. The false user-facing claim is corrected; the legacy schema screen itself is M8's. §K. |
|
||||
| L01, E01, E04, H05, C01–C03, C06, D01–D09, D11–D14, I01–I04, I07, J01–J02, L03 | PASS | **PASS** | Re-run green in the full suite. |
|
||||
|
||||
## P. Planning documents changed
|
||||
|
||||
- `STORY-BRANCH-SEMANTICS.md` — §§14-15 gain a "How this is built" section with
|
||||
the fork diagram, the two shapes, and the invariant. §14A is retitled
|
||||
*Superseded for Narrator Output*: it no longer says the finished behaviour is
|
||||
pending, and records why the refusal is gone for narrator turns and what still
|
||||
uses it.
|
||||
- `V1-ACCEPTANCE-TESTS.md` — D10's milestone-ownership note replaced by a PASS
|
||||
result naming the evidence for each of its three unchanged pass conditions.
|
||||
C04 gains a result recording what is fixed and what is deferred. **Neither
|
||||
criterion's pass conditions were altered.**
|
||||
- `planning/DECISIONS/013-authoritative-narrative-state-document.md` — new ADR
|
||||
recording the document shape, the authority/provenance fields, the
|
||||
events → document → snapshot pipeline, what each store is for, the invariant,
|
||||
and the consequences. It records what M5 built and invents nothing beyond it.
|
||||
- `BUILD-MILESTONES.md` — status line moved to M1-M5 complete with M6 next; the
|
||||
§14-15 note records that the corrective pass delivered it; a new "M5 — Outcome"
|
||||
section records what shipped and the debt assigned to M8, M9 and §13.
|
||||
- `README.md` — the play-loop feature bullet now says what a narrator correction
|
||||
does.
|
||||
- `planning/VERSION.md` — one line recording ADR 013.
|
||||
- `planning/reports/M4-IMPLEMENTATION-REPORT.md` → `planning/archive/milestone-reports/`
|
||||
(report rotation; a clean rename).
|
||||
|
||||
## Q. Verification summary
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| Backend suite | **794 passed, 3 skipped** (206s) |
|
||||
| Realistic model, one small local model | **3 passed** (805s); 6/6 turns accepted, 0 unparseable |
|
||||
| Frontend lint | exit 0, warnings pre-existing |
|
||||
| Frontend build | success |
|
||||
| Docker build | success |
|
||||
| Browser — standard M5 checks | **16/16** |
|
||||
| Browser — the previously failing edit case | **16/16** |
|
||||
| Action-list queries, 51 rows | 64 → **13** |
|
||||
| Pre-M5 Save Point restore | head, transcript and state agree |
|
||||
|
||||
## R. Is M5 safe to close, and is M6 safe to begin?
|
||||
|
||||
**Yes to both.**
|
||||
|
||||
The invariant M6 would have inherited is now held everywhere it was broken, and
|
||||
held by assertion rather than by argument: after a narrator edit, at every
|
||||
position visited across Undo and Redo, and after restoring to a migrated pre-M5
|
||||
position. The two failures the review called the same invariant reached by
|
||||
different routes are fixed by the same rule rather than by two special cases.
|
||||
|
||||
Nothing deferred blocks M6. Export of the audit trail is M9's own subject
|
||||
matter; the scenario editor is an M8 screen; §13 is an existing, guarded
|
||||
behaviour that M5 did not change and M6 does not build on.
|
||||
|
||||
**M5 CORRECTED — READY FOR CLOSEOUT / M6**
|
||||
Reference in New Issue
Block a user