Compare commits

..
Author SHA1 Message Date
JesseMarkowitzandClaude Opus 5 44edece67e M9: a campaign you can actually get back
A campaign could already be exported and imported. What could not survive the
trip was everything that explains it: the state events behind the authoritative
document, the prompt each turn was actually given, the passages it was shown,
the summaries that carry long-story continuity, and which take belonged to which
turn. An imported campaign could be read and could no longer say why it was what
it was — and a manual correction, the one state change no narration explains,
was indistinguishable from something the story had established.

The bundle is now `ai-dnd-adventure-v3`, and the version is the design rather
than a side effect. Everything added here could have been another optional key,
the way persona, Save Points, narrative state and imported knowledge each were.
That mechanism stops working at exactly this addition: a v2 file with no prompt
provenance is ambiguous between "written before M9" and "written by M9 from a
campaign that has none", and those are different facts about a campaign. A
version number is how a recovery file states what it was capable of recording.
v1 and v2 still import, and every seam from pre-active-head onward is tested for
the rule that an older file is never reinterpreted under a newer assumption.

Two categories became three. "Chosen travels, derived is recomputed" was enough
until stored prompts had to be decided: they are derived, and they must travel
anyway. The test that separates evidence from cache is not "could this be
recomputed" but "would a recomputation answer the same question" — a rebuilt
search index answers the same question, a rebuilt prompt says what the turn
would be told *now*, which is the opposite of what the inspector is for.

Also here: a real SQLite backup, through the online backup API rather than a
file copy, taken while the application is running and verified before it is
kept; story cards settled as compatibility-only legacy data and taken out of the
narrator's prompt, because they were the untracked path around knowledge
authority that IMPORTED-KNOWLEDGE-DESIGN §73 already forbade; and no schema
change at all, proved against a database M8's own code wrote.

Three defects, found by running the milestone's own tests rather than by reading
them. Deleting a campaign leaked its FTS index rows, and SQLite then handed the
freed ids to the next source imported into any campaign, which failed with an
integrity error that Reindex could not repair — both ends are closed, and a
database already carrying the damage now repairs itself. An imported node with
no state snapshot was being stamped with the campaign's head state, so an Undo
to turn 2 showed what the story knew at turn 20. And the snapshot relink did not
persist at all, because it mutated a dict in place on a column SQLAlchemy tracks
by assignment: it looked correct in memory and wrote the wrong ids to disk.

Carrying per-turn prompts looked like it would halve the length of campaign that
can be restored. Measured — and after compressing them inside the file —
everything M9 added costs 12% of it: the import ceiling moves from about 318
turns to about 279, against a 100-turn certification target. The dominant cost
is not M9's at all. The per-position narrative state document is 74% of a
bundle, and v2 already carried it.

Backend 1,102 passed / 14 skipped / 0 failed. Frontend 145 passed. Lint,
production build and Docker build clean. Verified across two server processes
with two data directories, and in a real browser against a real narrator.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qyn3oRd4D6pi72nKBG725B
2026-09-07 01:55:45 -04:00
JesseMarkowitzandClaude Opus 5 1ce9972760 M8: the browser becomes the storyteller
The interface was AI-DnD's with this product's features bolted into it. The
navigation read Home · Adventures · Scenarios · Settings · AI Chat; starting a
story meant first picking a *world*, and making a world meant a JSON stat-schema
form, a story-card table and an art picker. The play screen had a Branches tab.
The input had three modes. Sixteen of the sixteen controls on a two-turn story
had no accessible name — they were single glyphs with a tooltip.

All of that was measured in a real browser before anything was changed, and the
measurements are in planning/reports/M8-IMPLEMENTATION-REPORT.md §C. Almost
nothing underneath was wrong: the play loop, the history controls, the takes,
the Save Points, the state correction and the knowledge library all worked. What
was wrong was what a reader was asked to understand in order to use them.

So the shape now is one entry point and one screen:

  Campaigns -> Campaign -> Story
                           State · Knowledge · Context · Save Points · Settings

Everything that is not the story lives in a panel that starts closed. The
top navigation bar is hidden on the story screen entirely, because on that one
screen the story is the interface.

Play is one natural-language field. An action and a piece of quoted dialogue are
both just what the reader wrote, and B01/B02 confirmed against a real narrator
that the model reads the quotes without being told which kind of turn it is.
What survives from the old Story mode is a Story direction toggle, which is not
a fourth mode: it changes who is being spoken to, not what kind of action is
taken, and the box is visibly marked while it is on.

Branch, fork, node, merge and head appear nowhere a reader can see them. The
branch panel and the tree overlay are gone from the browser. The mechanism is
untouched — takes, divergence, retained futures and Save Points all still work,
and their endpoints are still tested. This is a decision about what a reader is
asked to understand, not a reduction of what the product can do.

The two defects worth the space:

A player action is stored with AI Dungeon's "> You " prefix. That was right when
the Do mode asked for a bare verb phrase. With one field the spec tells the
reader to write "I enter the tavern", and the result was "> You I enter the
tavern." — in the transcript, in the replayed history, and therefore in the
narration, where a small model imitates it and writes "You I thank her". M8's
own design surfaced it, so M8 fixed it: the prefix is added only when the reader
has not already written a subject. The ">" marker, which is what actually
identifies a player turn in the prompt, is unchanged in every case.

And a stale `.input-bar { display: flex }` in play.css overrode the new
composer, because that sheet is imported after the new one. The direction row
and the input row laid out side by side and the box was unusably narrow. Found
by opening the product in a browser, not by reading the CSS — which is the
argument for having done that first.

Failures now have the taxonomy the spec asked for rather than one toast: model,
generation, state, knowledge, server, each with the thing to do about it. A
failed turn leaves the reader's words in the box and says so. The classification
reads backend strings, so it is a fallback ladder rather than a lookup — an
unrecognised message still classifies, still shows the server's own words and
still offers Retry.

`Settings.model` could be empty with nothing saying so until the first turn
failed with a provider error. The header now reports Ollama in five states, and
an unconfigured or missing model offers the models actually installed on the
endpoint, from the connection test that already knew them. Nothing is chosen
automatically: an endpoint's first model may be an embedding model, which cannot
narrate at all.

Narrator prose is rendered as safe Markdown — headings, emphasis, lists,
blockquotes, code. The safety is structural rather than filtered: every node is
a React element built from parsed text, and there is no dangerouslySetInnerHTML
in the file. A sanitizer is not needed to make markup safe if markup is never
produced from input. Link schemes are checked with the URL parser rather than a
pattern, because the bypasses are all in the parsing. A remote image is a
placeholder naming the blocked address; the knowledge and context panels
deliberately do not use this renderer at all, because they exist to show a
reader exactly what is in their file.

Backend, and only what the browser could not otherwise reach:

  AdventureCreate.opening   a start action could only come from a Scenario, so
                            every campaign made in the new setup flow opened on
                            a blank page. Same node, same code path.
  canon_rules               campaign_canon has been the highest authority in a
                            campaign since M5, read by the prompt builder and
                            the state validator, and had no API at all — a
                            fixture had to write it with SQL.
  a 401 and a 429 message   the last user-facing text describing a hosted
                            deployment. One told the reader to check an API key
                            that has not existed since M2.

No schema change and no migration: proved by building a database with a server
running the M7 commit's own code and opening it with this one.

The project had no frontend tests. It has 132 now, across ten files, running
in about six seconds — the enabled state of every history control, the take
selector, the confirmations, the panels, the five model states, the failure
taxonomy, the focus trap, accessibility, and that the reserved dictation control
never touches the microphone. Writing them found a real defect: the focus trap
filtered candidates with offsetParent, which is null inside the fixed-position
ancestor the dialog has and which jsdom never computes — it would have behaved
differently in the tests from the browser.

They do not replace the real-browser runs, and both kinds of evidence are in the
report. The browser suites drive the production build served by the real backend
with a real local narrator, including a genuine process restart.

A verification pass over all of it then found three more, each by driving the
product rather than reading it:

Stepping between alternate takes did nothing. The pager asked whether a take
lived on another line by comparing `target.branch_id !== action.branch_id`, and
`ActionOut` has never carried `branch_id` — so the comparison was permanently
`number !== undefined`, always true, and every step took the branch-switch path.
For two takes of an ordinary retry, which share a line until one is written
below, that meant switching to the line already being read: the same window came
back and nothing moved. D07 is a required v1 acceptance test. The fix needed no
new field — the variants list already carries every attempt's branch and marks
the live one.

The first regression test for that passed against the broken code, because its
fixture gave the action a `branch_id` the real payload never sends. That is the
exact failure M7's review was about, so the fixture was corrected, the tests were
re-run against the reverted code and failed for the right reason, and the
fixture now carries a docstring saying why the field must never come back.

And the knowledge panel pointed readers at an "embedding model" while the
setting is called "Model for meaning-based search" — a reader sent looking for a
field that does not exist by that name.

Campaign canon was measured rather than assumed. Editing it after play is a
configuration change: every turn already played keeps the canon it was actually
given, in its own context snapshot, and the accepted story, the state document
and the state audit log are byte-identical across an edit. It is not routed
through M5's state audit, because canon is not narrative state and doing so
would create the second representation the spec forbids. What the editor does
now is say so, once a campaign has moments.

`BROWSER-UX-SPEC.md` §38 asked for a "Show Hidden Story State" toggle. There is
no hidden story state — a secret lives in a narrator-only knowledge source and
never enters the state document. The section is rewritten to require what it
actually meant: ordinary surfaces must not carry narrator-only information,
advanced inspection must withhold it by default behind an explicit warned
choice, and no second store may be invented to give a toggle something to
reveal. The protection is stricter than before, not weaker.

Closeout. An independent review returned M8 IMPLEMENTATION: PASS subject to
evidence and documentation cleanup, and this commit carries that cleanup:

The report named two frontend bundles as the artifact behind its acceptance
evidence. The saved run logs settle it. index-Ii-lARp9.js, built at 18:53:02
from this tree, is the one final frozen artifact behind all 157 browser checks;
index-C6E5Uvtu.js is superseded — it predates the D09 fix and its acceptance
suite ended 54/55 on exactly that defect. No tracked file under backend/app or
frontend/src has a modification time after the freeze, so the whole final
campaign describes one build. §P sets the two side by side.

Finding 14 — the app budgets 16,384 prompt tokens while an Ollama that sees no
VRAM enforces 4,096 — is resolved operationally, with no application change.
The OpenAI-compatible endpoint this app speaks accepts num_ctx and ignores it,
and reloads the model at its own default, so a native call cannot prime it
either. A model derived with POST /api/create carries the parameter, is honoured
through the app's own OpenAI-compatible path, and appears in /v1/models — which
is the listing the Settings model picker already reads. Measured end to end.
The procedure is in DEVELOPMENT.md; nothing in the repository depends on any
particular derived model existing. Adding provider code to work around this was
declined deliberately: it would mean either a second native request path,
against ADR 011, or a parameter the endpoint provably ignores.

The §38 rewrite is ratified as a requirement clarification aligned with the
implemented architecture, and the spec gains the clause finding 3 was really
about: withheld material must be absent from the rendered DOM, not merely
collapsed in it.

The report's §U carries the M9 handoff — what a portable campaign has to include,
whether historical context snapshots belong in the bundle, what happens to
inherited story cards, and that a restored campaign may meet a different context
window than the one that wrote it. None of it is implemented here.

Final: backend 950 passed / 14 skipped; frontend 132 passed; lint, production
build and Docker build clean; 157 browser checks across six suites, zero
failures. M8 is implemented, verified, reviewed and accepted (2026-09-06).
M9 has not been started.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017HdaXiFbscatQaLS7dJk6b
2026-09-06 23:31:45 -04:00
120 changed files with 21177 additions and 6675 deletions
+5
View File
@@ -9,6 +9,11 @@ __pycache__/
# Database
*.db
# M9: verified database backups land beside the database. `*.db` already covers
# the files; this names the directory so its purpose is obvious in a listing and
# so nothing else that ends up there is committed by accident.
backend/backups/
data/backups/
# Node
node_modules/
+196 -6
View File
@@ -214,13 +214,44 @@ visible from within.
## Tests
```bash
cd backend && .venv/bin/python -m pytest tests/ -q # 920 tests, 10 skipped
cd backend && .venv/bin/python -m pytest tests/ -q # the backend suite
cd frontend && npm test # the component suite (M8)
cd frontend && npm run lint && npm run build
```
Ten tests skip without something the machine may not have: seven need a second
machine or an environment the suite cannot create, and three are M7's real-model
tests below.
Fourteen backend tests skip without something the machine may not have: seven
need a second machine or an environment the suite cannot create, and the rest
are the real-model tests below.
### The frontend component suite
M8 added one, because until M8 there was none — the browser was covered by real
Firefox runs at each milestone's closeout and by nothing in between. It is
Vitest and Testing Library over jsdom, and it runs in about two seconds:
```bash
cd frontend && npm test # once
cd frontend && npm run test:watch # while working
```
It covers the deterministic browser behaviour M8 owns: which history controls
are enabled and why, the take selector, the Save Point and delete confirmations,
what the State panel shows and does not, knowledge classification and semantic
status, the context inspector's sections, the model-empty and model-unavailable
states, how failures are presented, the dialog focus trap, and that the reserved
dictation control never touches the microphone. Several tests assert the absence
of branch vocabulary in the surfaces a reader uses.
`markdown.test.jsx` is the security one. Narrator prose and imported text both
reach the renderer, so it is where H06 and H07 are decided: markup in the source
never becomes markup in the page, a `javascript:` URL never becomes an href, and
a remote image is a placeholder rather than a request.
**It does not replace the real-browser runs.** jsdom has no layout, no
navigation and no network, so scroll behaviour, streaming, a genuine process
restart and the CSP are all outside its reach. Each milestone's closeout drives
a real Firefox over WebDriver, and that evidence is recorded in the milestone
report.
Two files are the M1 regression guards.
@@ -297,6 +328,160 @@ subsystem comes back as a route, if an API key becomes settable again, if the
model timeout stops being configurable or becomes unbounded, or if a supported
start path stops binding loopback.
## Backing up, and getting a campaign back
There are two recovery tools and they answer different questions. Using the
wrong one is the most common way to be surprised later, so they are described
together.
| | Campaign export | Database backup |
| --- | --- | --- |
| Covers | one campaign | every campaign, and your settings |
| Shape | a JSON file you can read | a copy of the SQLite database |
| Moves between machines | **yes** — this is the supported way | no; it is this machine's database |
| Taken from | Export, on a campaign | Settings → *Back up everything on this machine* |
| Restored by | Import campaign, on the library screen | replacing the database file, below |
### Exporting and importing a campaign
Export is on each campaign in the library, and in the campaign's own Settings
panel. It writes one `.json` file holding the whole campaign: the story and its
entire retained tree, the branch you are on and **the exact position you are
reading at** — including one you undid back to — every alternate take, your Save
Points, the authoritative state and its per-position snapshots, the state
history that explains it, your imported knowledge with its classifications, the
summaries and memories, and the prompt each turn was actually given.
Import is on the library screen and takes that file back, into this or any other
installation. Nothing about the file refers to the machine that wrote it: the
imported files come back from their content, not from a path, and no setting of
yours is changed by importing somebody's campaign.
Two things it deliberately does **not** carry: your inference endpoint and model
settings, which describe your machine rather than the campaign, and the
rebuildable search indexes, which are rebuilt from the imported content before
the import returns.
**A campaign imports whether or not the model that wrote it is installed here.**
Recovering a campaign and being able to play it on are separate questions; the
first never depends on the second.
### Backing up the whole database
Settings → Advanced → *Back up everything on this machine*. It writes a verified
copy into a `backups/` directory beside the database itself, and tells you where.
It is a real backup rather than a file copy. It uses SQLite's online backup API,
so it is safe to take **while you are playing** — a `cp` of a live database can
read one page before a transaction and another after it, producing a file that
opens, reports a schema, and is quietly missing rows. The copy is checked with
`PRAGMA quick_check` before it is kept, an existing backup is never overwritten,
and a failure leaves nothing behind.
You can also take one from the command line, or from `cron`:
```bash
curl -s -X POST http://127.0.0.1:8000/api/backups | python3 -m json.tool
```
### Restoring a whole database
There is deliberately no restore button, because restoring means replacing the
file the running application has open — which is how you lose both copies at
once. It is a three-step procedure and each step needs the application stopped:
```bash
# 1. Stop the application. Nothing below is safe while it is running.
# (Ctrl-C the server, or `docker compose down`.)
# 2. Keep what is there now, whatever state it is in. You may want it back.
mv backend/data.db backend/data.db.before-restore
# 3. Put the backup in its place, and start the application again.
cp backend/backups/adventure-storyteller-20260907-043000.db backend/data.db
```
Check the file before you trust it, and check it again after starting:
```bash
sqlite3 backend/backups/adventure-storyteller-20260907-043000.db 'PRAGMA quick_check;'
# -> ok
```
The database path is `backend/data.db` by default, and whatever `AIDND_DB_PATH`
names otherwise — in Docker that is the mounted volume.
There is one file to move and no others: this build leaves SQLite in its default
rollback-journal mode, so there are no `-wal` or `-shm` companions beside the
database (`PRAGMA journal_mode` reports `delete`). A build that switched to WAL
would have to move those too, and leaving them behind would pair a new database
with an old write-ahead log.
**Prefer the campaign export for anything smaller than "everything".** Restoring
a whole database rolls every campaign back to the moment the backup was taken,
including the ones you did not mean to touch. To recover one campaign, export it
and import it.
## The context window your Ollama actually enforces
**Check this before a long campaign.** The application budgets a prompt up to
`Settings.context_token_budget` (16,384 by default). Ollama enforces its own
input window, and when it sees no VRAM it defaults to **4,096**:
```
level=INFO msg="vram-based default context" total_vram="0 B" default_num_ctx=4096
```
Confirm what yours is:
```bash
curl -s http://127.0.0.1:11434/api/ps | python3 -m json.tool | grep context_length
```
If that number is smaller than your budget, Ollama silently truncates the input
— and `llama.cpp` drops the **oldest** tokens, which in this application is the
system block: the narrator rules and the campaign canon. The symptom is a
narrator that forgets canon deep into a long session, with nothing on screen
explaining why.
**Setting it per request does not work from this application.** Ollama's
OpenAI-compatible endpoint accepts `num_ctx` — nested in `options` or at the top
level — returns HTTP 200 and ignores it. Worse, it *reloads the model at its own
default*, so priming the server with a native `/api/chat` call first does not
help either: the app's next request resets the window.
**Bake it into a model instead.** The window travels with the model, and this
needs no shell access on the Ollama host — it is a normal API call:
```bash
curl http://127.0.0.1:11434/api/create -d '{
"model": "qwen2.5:3b-instruct-16k",
"from": "qwen2.5:3b-instruct",
"parameters": {"num_ctx": 16384}
}'
```
The derived model shares the base model's blobs, so it costs a manifest. It then
appears in `/v1/models`, which is the listing the Settings model picker reads —
select it there and the storyteller gets the full window through its ordinary
OpenAI-compatible path. Remove it with `POST /api/delete` when you are done.
Where you *do* control the server environment, `OLLAMA_CONTEXT_LENGTH=16384`
does the same job. Either way a larger window costs roughly proportionally more
KV cache.
If you would rather not raise it at all, set **How much story to send** in
Settings to the number `/api/ps` reports, and the prompt will be assembled to
fit.
**This matters most on the machine you import to.** A campaign carries its
history, not the window the machine that wrote it had, and a long imported
campaign fills a prompt on its very first turn — so a deployment that has applied
neither the derived model above nor a matching budget meets its ceiling
immediately rather than gradually. Importing succeeds either way; it is the first
turn afterwards that truncates. Check `/api/ps` on the destination before playing
on an imported campaign, not after.
## What was made offline-safe, and how to check
Two runtime downloads were removed in Milestone M1. Both were invisible on a
@@ -350,5 +535,10 @@ left of upstream that a newcomer might report as a defect:
- **`.github/workflows/ci.yml`** is upstream's GitHub Actions pipeline. This
repository lives on a self-hosted Gitea; the workflow is kept for provenance
and is not what runs the tests here.
- **No frontend tests.** `npm run lint && npm run build` is the whole frontend
check. A test runner is M8's job.
- **A thin `components.jsx`.** What is left of upstream's shared component
module is a toast host, a file picker, a JSON download and an auto-growing
textarea. M8 removed the rest with the screens that used them — the scenario
art generator, the placeholder modal, the story-card row.
(Removed from this list by M8: **no frontend tests**. There is a component suite
now — see Tests above.)
+74 -35
View File
@@ -3,8 +3,11 @@
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
An interactive storytelling app that runs entirely on your own machine, with your own model.
Create scenarios and play open-ended adventures where a local LLM narrates the world, keeps
track of what is true, and remembers what happened.
Start a campaign from a short form and play an open-ended story where a local LLM narrates the
world, keeps track of what is true, and remembers what happened. Since M8 the browser has one
entry point — a campaign library — and one natural-language input; the scenario gallery and its
editor are gone from the interface, though the AI Dungeon-compatible scenario *format* is still
supported for import and export.
This is the **Adventure Storyteller** fork of [AI-DnD](https://github.com/parththakkar106/AI-DnD).
It is deliberately narrower than its upstream: single-user, local-only, and pointed at a model
@@ -31,19 +34,26 @@ 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. 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
branch. Branches borrow their ancestors' turns instead of copying them, so a fork costs about
100 bytes, and a 20-fork story loads within 1% of the same story flat. Switching restores that
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`).
- **The full play loop, in one box.** You write what you do or say in a single
natural-language field — an action and a piece of quoted dialogue are both just what you
wrote — with **Continue** for a beat you do not act in and a **Story direction** toggle for
speaking to the narrator rather than in the story. Responses stream (SSE), and Undo, Redo,
Retry and Edit sit beside the box. 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: the
narrator's thinking streams into a collapsible panel with its own token budget.
- **Retained history, without a tree to manage.** Underneath, the story is a tree: 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 is not
the live one is what starts a different continuation. Branches borrow their ancestors' turns
instead of copying them, so one costs about 100 bytes, and a 20-fork story loads within 1% of
the same story flat (`backend/app/tree.py`, `backend/app/context/lineage.py`).
**None of that vocabulary reaches the reader.** M8 removed the branch panel and the tree
overlay from the browser: what you get is Undo, Redo, Retry, takes, Save Points and Restore,
and nothing on screen says branch, fork, node or head. The mechanism is unchanged and still
fully tested — this is a decision about what you are asked to understand, not about what the
product can do.
- **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.
@@ -55,17 +65,25 @@ that isn't the live one starts a new branch.
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`). Story cards are the inherited authored-lore primitive and
are kept; they are **not** the knowledge library below, which is a first-class subsystem with
its own classification, provenance, chunking and index.
- **Insights: total prompt transparency.** Every turn stores the exact prompt sent to the
model. Open 🔍 on any AI action to see each context component, its token cost, and why it was
included.
- **A context engine you can account for.** Memory, the author's note, the campaign's own
rules, the authoritative state, the summary that applies here, and the retrieved imported
passages are assembled under one token budget, in an order chosen so that a section which
changes does not re-price the cached prefix above it (`backend/app/context/builder.py`).
**Story cards** — AI Dungeon's world-info primitive, inherited with the fork — are kept as
legacy data and travel with an export, but they no longer reach the narrator. A keyword-matched
card used to arrive in front of it as a world fact with no class, no visibility, no source and
nothing to switch it off, competing with your imported Canon for the same budget; the knowledge
library below replaces it, and does all of that explicitly.
- **Total prompt transparency.** Every turn stores the exact prompt sent to the model.
**Inspect context** on any narrator turn opens a readable account of what it was given —
what it remembered, what it read, what it believes, and what each part cost — with the
assembled prompt itself kept as an advanced section rather than opening on a wall of text.
A passage that came from an imported file links back to the file it came from.
- **Auto-summarization and Memory Bank.** The modern AI Dungeon memory system: AI-generated
memories every few actions, a running story summary, and embedding-based retrieval that
pulls old-but-relevant facts back into context, with similarity scores visible in Insights
pulls old-but-relevant facts back into context, with similarity scores visible in the
context inspector
(`backend/app/memorybank.py`).
- **An imported knowledge library, classified by how much authority it has.** Import your own
local `.txt` and `.md` files — a setting bible, character notes, research, a passage whose
@@ -80,7 +98,7 @@ that isn't the live one starts a new branch.
path, not a fallback — the library works with no embedding model at all. Canon you mark
**always include** is supplied on every turn whether or not the scene resembles it, and Canon
you mark **narrator only** is given to the narrator with instructions not to let the
protagonist know it. Every passage that reaches a prompt is listed in Insights with its file,
protagonist know it. Every passage that reaches a prompt is listed in the context inspector with its file,
class, heading, passage number, scores and token cost, and that record is kept in the turn, so
deleting a source never erases the evidence of what an old turn was shown
(`backend/app/knowledge/`).
@@ -109,13 +127,33 @@ that isn't the live one starts a new branch.
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, 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.
- **Import and export, as a recovery contract.** A campaign exports as one JSON file,
`ai-dnd-adventure-v3`, and imports into a clean install on another machine. It carries the whole
tree — 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 — and, since it is meant to be *recovery*
rather than a copy of the text, everything that explains that story: the authoritative state
and the typed events behind it, **the exact prompt each turn was given and the passages it was
shown**, the summaries with the coordinates that decide whether they still apply, and your
imported files with their classifications. A restored campaign can still answer "why does the
state say this?" and "what was the narrator actually told?" — after the source file has been
deleted and the canon edited since.
A campaign opens where its head says, never at a Save Point merely because it has one. Exported
after two Undos, it imports still undone, with its retained future intact. Search indexes are
not carried: they are rebuilt from the content, before the import returns. Nothing about your
machine travels — no endpoint, no model, no path — so importing somebody's campaign never
reconfigures your inference, and a campaign imports whether or not you have the model that
wrote it. Older files still import: the flat single-line format, files that predate the head
position, and files that predate everything above. AI Dungeon-compatible scenario format is
still read and written for scenarios and story cards.
- **A verified backup of everything, taken while you play.** Settings → *Back up everything on
this machine* writes a copy of the whole database through SQLite's online backup API — not a
file copy, which of a live database can read one page before a transaction and another after it
and produce a file that opens and is quietly missing rows. It is checked with `PRAGMA
quick_check` before it is kept, and an existing backup is never overwritten
(`backend/app/backup.py`). Restoring one is a documented stop-move-start procedure in
`DEVELOPMENT.md`, deliberately not a button: replacing the file the running application has
open is how you lose both copies.
- **Single user, no accounts.** There is no sign-up, no login, no session and no API key
anywhere in the product. The storyteller API binds to loopback and is unauthenticated by
design, because the only person who can reach it is the person running it. A new install
@@ -221,8 +259,8 @@ leave it there.
player input
→ assemble context: [narrator prompt] + [world state + stat guide] + [AI instructions]
+ [plot essentials] + [story summary] + [retrieved memories]
+ [triggered story cards] + [retrieved imported knowledge,
framed by class and bounded by its own budget]
+ [retrieved imported knowledge, framed by class and
bounded by its own budget]
+ [history along this branch, token-budgeted]
+ [author's note] + [player action]
→ snapshot context (Insights)
@@ -249,7 +287,8 @@ frontend/ React + Vite SPA ──HTTP/SSE──► backend/ FastAPI
├─ worldstate/ the inherited RPG stat engine — legacy, no longer authoritative
├─ memorybank.py auto-summarization + embedding retrieval
├─ knowledge/ the imported library: import, chunk, FTS5, embed, rank, inject
├─ bundle.py the export/import formats, v2 (tree) and a v1 reader
├─ bundle.py the export/import formats: v3, and readers for v2 and v1
├─ backup.py a verified whole-database copy, via SQLite's backup API
├─ providers/ OpenAI-compatible adapter, streaming
└─ data.db SQLite (path overridable via AIDND_DB_PATH)
```
+278
View File
@@ -0,0 +1,278 @@
"""M9: a consistent copy of the whole database, taken while the app is running.
This is **not** the campaign bundle, and the two are not alternatives. They are
different recovery tools and M9 keeps them apart deliberately:
campaign bundle one campaign, logical, portable between installations,
importable into a clean data directory on another
machine, readable by a human and by a later build
database backup every campaign, every setting, physical, this machine,
restored by putting the file back
The bundle is the primary cross-install recovery path and is what the acceptance
tests measure. This exists for the other question: the reader has one database
holding everything they have ever played, and wants a copy of it before they
upgrade, move a disk, or try something they might regret.
## Why not `cp data.db backup.db`
Because a copy taken with the application running is a copy of a moving target.
SQLite writes a database in pages, and a plain file copy can read page 5 before
a transaction and page 900 after it — the result is a file that opens, reports a
schema, and is silently missing or duplicating rows. In WAL mode it is worse: the
committed data may be in a `-wal` file the copy never touched. Nothing warns
anyone. The corruption is found later, by which time the original may be gone.
So this uses SQLite's own **online backup API** (`sqlite3.Connection.backup`),
which is the supported mechanism for exactly this: it copies page by page while
holding the right locks, restarts if a write moves the source underneath it, and
produces a file that is a transactionally consistent snapshot of some committed
point. The application keeps running throughout; no session is closed and no
turn is blocked.
## What the procedure guarantees
1. The source database is opened **read-only** and is never written to. A backup
that could damage what it is backing up would be worse than no backup.
2. The copy is written to a temporary file beside the destination and renamed
into place only after it has been verified, so an interrupted or failed run
never leaves a half-written file wearing a backup's name. `os.replace` is
atomic on the same filesystem, which is why the temporary sits in the
destination's own directory rather than in `/tmp`.
3. `PRAGMA quick_check` runs against the finished copy, opened as its own
database, before it is renamed. A backup nobody verified is a belief.
4. An existing file is never overwritten. Each run writes a new name stamped
with the time, so yesterday's backup survives today's mistake — which is most
of what a backup is for.
5. Failure is reported and leaves nothing behind but the log line.
## What it does not do
There is no restore endpoint. Restoring a whole database means replacing the
file the running application has open, and doing that from inside that
application is a way to lose both copies. The procedure is in `DEVELOPMENT.md`:
stop the app, move the file into place, start it. Campaign-level recovery — the
common case, and the one that crosses machines — is the bundle.
No path comes from a caller. The destination directory is derived from the
database the application is already using and the filename is generated here, so
there is no request that can direct a write anywhere else (H08).
"""
from __future__ import annotations
import logging
import os
import sqlite3
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from .database import DB_PATH
log = logging.getLogger(__name__)
#: Where backups go: a directory beside the database itself. Beside, rather than
#: inside a configurable location, because the one thing this must not do is
#: write somewhere a request can name.
DIRECTORY_NAME = "backups"
#: The stem every backup file carries, so a directory listing sorts by date and
#: says what these files are without being opened.
PREFIX = "adventure-storyteller"
class BackupError(RuntimeError):
"""A backup did not complete. The source database is untouched."""
@dataclass(frozen=True)
class Backup:
"""One finished, verified backup file."""
path: Path
bytes: int
pages: int
seconds: float
integrity: str
def as_dict(self) -> dict:
return {
# The name alone, not the path. The full path is a fact about this
# machine's filesystem, and the reader is told the directory once by
# the endpoint that lists them.
"filename": self.path.name,
"bytes": self.bytes,
"pages": self.pages,
"seconds": round(self.seconds, 3),
"integrity": self.integrity,
}
def directory(db_path: Path | None = None) -> Path:
"""The backup directory for a database, created if it does not exist."""
root = (db_path or DB_PATH).parent / DIRECTORY_NAME
root.mkdir(parents=True, exist_ok=True)
return root
def create(db_path: Path | None = None, *, now: datetime | None = None) -> Backup:
"""Takes one verified backup of the live database, and returns it.
Raises `BackupError` on any failure, having removed whatever it had written.
The source database is opened read-only and is never modified, so a failure
here costs the backup and nothing else.
"""
source_path = db_path or DB_PATH
if not source_path.exists():
raise BackupError(f"There is no database at {source_path}.")
stamp = (now or datetime.now()).strftime("%Y%m%d-%H%M%S")
target = _unused_name(directory(source_path), stamp)
# The temporary sits in the destination directory so the rename below is a
# rename rather than a copy across filesystems, which would not be atomic.
working = target.with_name(target.name + ".partial")
started = datetime.now()
try:
pages = _copy(source_path, working)
integrity = _verify(working)
except BackupError:
_discard(working)
raise
except Exception as exc: # noqa: BLE001 - reported, never raised raw
_discard(working)
log.exception("Backup of %s failed", source_path)
raise BackupError(f"{type(exc).__name__}: {exc}") from exc
size = working.stat().st_size
# Only now does the file get the name a reader would trust.
os.replace(working, target)
return Backup(
path=target,
bytes=size,
pages=pages,
seconds=(datetime.now() - started).total_seconds(),
integrity=integrity,
)
def _copy(source_path: Path, working: Path) -> int:
"""Runs SQLite's online backup from `source_path` into a new file.
The source is opened through a URI with `mode=ro`, so this connection cannot
write to it even by accident. The destination is a fresh database that this
function creates; `backup()` overwrites whatever is in it, and the caller has
guaranteed the name is unused.
Returns the number of pages copied, which is the one honest measure of how
much was actually written — the file size counts pages the source had
already allocated.
"""
source = sqlite3.connect(f"file:{source_path}?mode=ro", uri=True)
try:
destination = sqlite3.connect(working)
try:
copied = 0
def progress(_status, remaining, total):
nonlocal copied
copied = total - remaining
# `pages=-1` copies the whole database in one step while holding the
# source's read lock, which is the right trade for a local
# single-user database: it is the fastest option, it cannot restart
# partway, and the lock it holds does not block readers.
source.backup(destination, pages=-1, progress=progress)
return copied
finally:
destination.close()
finally:
source.close()
def _verify(working: Path) -> str:
"""Runs `PRAGMA quick_check` against the finished copy.
Opened as its own connection, so what is checked is the file on disk rather
than any page cache the copy left behind. `quick_check` rather than
`integrity_check` because it does the structural work — every page reachable,
every record readable — without the full index cross-check, which on a large
database is minutes rather than moments. A backup nobody verified is a
belief; a backup verified slowly enough that nobody takes one is worse.
"""
connection = sqlite3.connect(f"file:{working}?mode=ro", uri=True)
try:
rows = connection.execute("PRAGMA quick_check").fetchall()
finally:
connection.close()
result = ", ".join(str(row[0]) for row in rows) if rows else "no result"
if result != "ok":
raise BackupError(
f"The backup was written but did not verify: {result}. It has been "
f"discarded; the original database is untouched."
)
return result
def _unused_name(root: Path, stamp: str) -> Path:
"""A name in `root` that nothing is using.
An existing backup is never overwritten. Two backups taken inside one second
are the only way to collide, and the counter settles that rather than one of
them silently replacing the other.
"""
candidate = root / f"{PREFIX}-{stamp}.db"
counter = 2
while candidate.exists() or candidate.with_name(candidate.name + ".partial").exists():
candidate = root / f"{PREFIX}-{stamp}-{counter}.db"
counter += 1
return candidate
def _discard(working: Path) -> None:
"""Removes a partial file, ignoring a file that is already gone."""
try:
working.unlink()
except OSError:
pass
def existing(db_path: Path | None = None) -> list[dict]:
"""Every backup in the directory, newest first.
Names and sizes only. Reading one to report what is inside it would mean
opening a database on every page load for a screen that is a list.
`taken_at` is read out of the **filename**, which is the stamp `create`
wrote when it took the backup, and falls back to the file's modification
time only for a name that does not parse. The two usually agree, and where
they disagree the name is the one telling the truth: copying a backup to
another disk, restoring it from an archive, or touching it all move the
mtime, and a list that then reordered itself would report when the file was
last handled rather than when the backup was taken.
"""
root = directory(db_path)
rows = []
for path in root.glob(f"{PREFIX}-*.db"):
try:
stat = path.stat()
except OSError:
continue
rows.append({
"filename": path.name,
"bytes": stat.st_size,
"taken_at": (
_stamp_in(path.name) or datetime.fromtimestamp(stat.st_mtime)
).isoformat(timespec="seconds"),
})
rows.sort(key=lambda row: (row["taken_at"], row["filename"]), reverse=True)
return rows
def _stamp_in(filename: str) -> datetime | None:
"""The time in a backup's name, or `None` if it does not carry one."""
rest = filename[len(PREFIX) + 1:].removesuffix(".db")
# A collision within one second gets a `-2` suffix, which is not the stamp.
stamp = "-".join(rest.split("-")[:2])
try:
return datetime.strptime(stamp, "%Y%m%d-%H%M%S")
except ValueError:
return None
+898 -47
View File
File diff suppressed because it is too large Load Diff
+43 -24
View File
@@ -29,7 +29,9 @@ from ..knowledge import records as knowledge_records
from . import encoding, history
AUTHORS_NOTE_DEPTH = 3 # actions from the end of history
CARD_BUDGET_SHARE = 0.4 # max share of non-reserved budget that story cards may take
# `CARD_BUDGET_SHARE = 0.4` was here, and is gone with the injection it bounded
# (M9). It is named rather than deleted silently because two other places
# reasoned about their own share against it.
NPC_WINDOW = 6 # actions of story searched for NPC trigger words ("in scene")
SEPARATOR = "\n\n"
@@ -508,30 +510,47 @@ def build_context(
adventure, available_after_knowledge, count_tokens, exclude_action_id
)
# ----- Story cards: triggered by recent story text (the window history could fill) -----
trigger_window = truncate_to_last_tokens(
SEPARATOR.join(a.text for a in actions), available_after_knowledge
)
triggered = match_cards(adventure.story_cards, trigger_window)
card_budget = int(available_after_knowledge * CARD_BUDGET_SHARE)
card_records = []
lore_lines: list[str] = []
# ----- Story cards: legacy, and no longer part of the narrator's prompt (M9)
#
# Until M9 a keyword-triggered story card was injected here as
# `World Lore: <entry>`, taking up to 40% of what was left after the
# imported knowledge had been placed.
#
# `IMPORTED-KNOWLEDGE-DESIGN.md` §73 settles that Story Cards are not the
# production imported-knowledge store and, in as many words, that they "must
# not become an alternate untracked path around the new knowledge
# authority/provenance rules". That is exactly what this was. A card entry
# arrived in front of the narrator as a world fact with:
#
# * no class — nothing said whether it was Canon, Reference or Inspiration,
# so nothing framed how far the narrator could rely on it;
# * no visibility — no narrator-only distinction at all;
# * no source, no hash, no lifecycle, nothing to disable it with;
# * no browser surface, since M8 removed the editor — so a reader could
# neither see it nor switch it off;
# * and no row in the context inspector, which renders `knowledge` and
# never rendered `cards`.
#
# It also competed with imported Canon for one budget, which is the
# arrangement M7 spent a milestone separating.
#
# M9's decision, recorded in the milestone report: story cards are
# **compatibility-only legacy data**. Nothing is deleted. The rows stay, the
# `/api/story-cards` endpoints stay, the bundle carries them out and back so
# a round trip destroys nothing, and `memorybank.cast_brief` still reads them
# as the summariser's character roster — a roster names who is on stage so a
# memory says "Aldric" rather than "he", it never reaches the narrator, and
# every memory written from it is authority-classified by the application
# afterwards. What stops is the one path that asserted campaign facts to the
# narrator without any of the controls §73 requires.
#
# `cards` stays in the report and is now always empty for a new turn.
# Removing the key would break the historical snapshots that have one, which
# M9 has just made portable: an old turn's evidence says story cards were
# included, and it must go on saying so.
card_records: list[dict] = []
lore_section = None
used = 0
for match in triggered:
line = f"World Lore: {match['entry'].strip()}"
tokens = count_tokens(line)
included = used + tokens <= card_budget
if included:
lore_lines.append(line)
used += tokens
card_records.append(
{"id": match["id"], "name": match["name"], "keyword": match["keyword"],
"included": included}
)
lore_section = (
Section("world_lore", "\n".join(lore_lines)) if lore_lines else None
)
# ----- Story history: newest first until the remaining budget is spent -----
history_budget = available_after_knowledge - used
+42 -2
View File
@@ -137,13 +137,53 @@ def index_line(heading_path: str, text_: str) -> str:
def add(db: Session, chunk_id: int, heading_path: str, text_: str) -> None:
"""Indexes one passage. The caller supplies the chunk's id as the rowid."""
"""Indexes one passage. The caller supplies the chunk's id as the rowid.
`OR REPLACE`, and the reason is a defect M9 found rather than a defensive
habit. The rowid is a chunk's primary key, so a row already sitting at it is
by definition stale: the chunk that owned it does not exist, or is being
rewritten by the reindex that called this. Either way the new passage is the
truth and the old row is not.
Without it, an orphaned index row makes an ordinary import fail. SQLite
reuses primary keys once the highest row is gone, so the next campaign to
import a source is handed rowid 1 again, collides with an orphan, and gets a
500 from `INSERT` — and `clear_index` cannot clear the orphan, because it
finds index rows *through* the chunks, and there are none. That made Reindex,
which is the documented repair, unable to repair this. `REPLACE` closes it
from both ends: a leaked row is overwritten the moment the id comes round
again, so an existing database repairs itself rather than needing a
migration, and Reindex is the repair it is described as.
The leak itself is closed separately, in `importer.clear_campaign_index`.
"""
db.execute(
sql(f"INSERT INTO {TABLE} (rowid, text) VALUES (:id, :text)"),
sql(f"INSERT OR REPLACE INTO {TABLE} (rowid, text) VALUES (:id, :text)"),
{"id": chunk_id, "text": index_line(heading_path, text_)},
)
def remove_adventure(db: Session, adventure_id: int) -> int:
"""Drops every index row belonging to one campaign. Returns how many.
Scoped through the chunks, which is the only place the campaign is
recorded — the index deliberately holds no copy of it
(see "The table" above). So this has to run **before** the chunk rows go,
which is what `importer.clear_campaign_index` is for.
"""
result = db.execute(
sql(
f"""
DELETE FROM {TABLE} WHERE rowid IN (
SELECT id FROM knowledge_chunks WHERE adventure_id = :adventure_id
)
"""
),
{"adventure_id": adventure_id},
)
return result.rowcount or 0
def remove_chunks(db: Session, chunk_ids: list[int]) -> None:
"""Drops passages from the index by id.
+22
View File
@@ -362,6 +362,28 @@ def clear_index(db: Session, source: models.KnowledgeSource) -> None:
db.expire(source, ["chunks"])
def clear_campaign_index(db: Session, adventure: models.Adventure) -> int:
"""Removes a whole campaign's lexical index rows. Returns how many.
Called before a campaign is deleted, and it has to be: the FTS index is a
virtual table, so no foreign key reaches it and no `ON DELETE CASCADE`
covers it. Deleting a campaign cascades `knowledge_sources` to
`knowledge_chunks` and stops there, leaving one index row per passage
belonging to a chunk that no longer exists.
Found in M9. The leak is not cosmetic. SQLite hands out the lowest free
primary key, so once the highest chunk is gone the *next* source imported
into *any* campaign is given a chunk id that an orphan already occupies, and
the import fails with an integrity error — a 500 on an ordinary upload, in a
campaign that has nothing to do with the deleted one. `fts.add` now repairs
such a collision when it meets one; this stops it happening.
Vectors and passages need no equivalent, because both are real tables whose
foreign keys cascade.
"""
return fts.remove_adventure(db, adventure.id)
def delete_source(db: Session, source: models.KnowledgeSource) -> None:
"""Removes a source and everything derived from it.
+11 -4
View File
@@ -50,10 +50,17 @@ from .records import Candidate, Result
#: Share of the non-protected budget that retrieved knowledge may spend.
#:
#: Story cards already take up to 40% (`CARD_BUDGET_SHARE`), and the history is
#: what is left. A third is enough for several passages at the chunker's
#: typical size and leaves the majority of the window to the story itself,
#: which is the thing the reader came for.
#: A third is enough for several passages at the chunker's typical size and
#: leaves the majority of the window to the story itself, which is the thing the
#: reader came for.
#:
#: This share was chosen when story cards could take up to 40% of the same
#: budget and the history took what was left. M9 removed that injection
#: (`IMPORTED-KNOWLEDGE-DESIGN.md` §73), so the history now gets that 40% back.
#: The number here is deliberately unchanged: a third of the budget was chosen
#: as the right amount of *imported material* to put in front of the narrator,
#: not as a leftover, and raising it because room appeared would be changing
#: retrieval behaviour under cover of a portability milestone.
KNOWLEDGE_SHARE = 0.33
#: What each class may take of the knowledge budget. Canon may take all of it;
+5 -1
View File
@@ -10,7 +10,9 @@ from starlette.exceptions import HTTPException as StarletteHTTPException
from .database import engine
from .limits import BodySizeLimitMiddleware
from .migrations import bootstrap
from .routers import adventures, chat, debug, scenarios, settings, story_cards
from .routers import (
adventures, backups, chat, debug, scenarios, settings, story_cards,
)
from .seed import seed_public_scenarios
bootstrap(engine)
@@ -112,6 +114,8 @@ app.include_router(scenarios.router)
app.include_router(adventures.router)
app.include_router(story_cards.router)
app.include_router(settings.router)
# M9: a verified copy of the whole database, taken while the app is running.
app.include_router(backups.router)
app.include_router(chat.router)
app.include_router(debug.router)
+16
View File
@@ -155,6 +155,22 @@ class Adventure(Base):
# 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)
@property
def canon_rules(self) -> list[str]:
"""The `rules` list alone, which is the half a person writes.
`campaign_canon` also carries `forbidden_status_changes`, a structured
shape the browser has no editor for and does not need one for — a rule
like "nothing dead becomes alive" is expressible as a sentence. So the
API exposes the sentences and leaves the structured half to whatever
wrote it, rather than round-tripping a shape the UI would flatten.
"""
canon = self.campaign_canon
if not isinstance(canon, dict):
return []
rules = canon.get("rules")
return [r for r in rules if isinstance(r, str)] if isinstance(rules, list) else []
# 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.
+18 -10
View File
@@ -381,21 +381,29 @@ class OpenAICompatibleProvider(Provider):
return vectors
def _friendly_http_error(self, status: int, detail: str) -> str:
"""The message a reader sees when the endpoint answers with an error.
M8 rewrote two of these. They were the last user-facing text describing
a hosted deployment this build does not have: a 401 advised checking an
API key, and a 429 explained a shared free tier's daily cap. There is no
API key field — M2 removed it with the cloud providers — and no shared
tier, so both sent a reader looking for a setting that does not exist.
Ollama's own 401 and 429 mean something else entirely.
"""
if status == 401:
return "Authentication failed — check your API key in Settings."
return (
"The endpoint refused the request as unauthorized (HTTP 401). "
"An ordinary local Ollama does not require authentication — "
f"check that {self.base_url} is the endpoint you meant. {detail}"
)
if status == 404:
return (
f"Endpoint or model not found (HTTP 404). Check the endpoint URL and that "
f"model '{self.model}' exists. {detail}"
)
if status == 429:
# OpenRouter's shared free tier has a per-day cap. Distinguish it
# from a short-term burst limit, so the message tells the reader what
# to do.
if "free-models-per-day" in detail:
return (
"The free demo has hit its daily request limit (resets at "
"00:00 UTC). Please try again later."
)
return "The AI is getting too many requests right now — wait a moment and try again."
return (
"The endpoint is refusing further requests for now (HTTP 429). "
"Wait a moment and try again."
)
return f"AI endpoint returned HTTP {status}: {detail}"
+47 -12
View File
@@ -1,7 +1,31 @@
"""Exporting an adventure to a bundle, and importing one back.
`app/bundle.py` owns the format and the version handling. These two endpoints
only check ownership and hand the work over.
only check ownership, apply the caps, and hand the work over.
## Why the import is one transaction and two phases
`bundle.plan` reads the whole file and returns a checked, normalised tree
without opening a session, touching a row or creating an adventure. Everything a
hand-edited file can get wrong about its own shape — a node on a branch that is
not listed, a fork from a branch listed after it, a head past the story, an
audit record naming a turn that is not there — is a 400 from a function with no
side effects.
Only then does `bundle.materialize` write, and it writes inside the single
transaction this endpoint commits at the end. So there are exactly two outcomes
a caller can see, and M9 requires them to be distinguishable:
the authoritative import failed 4xx, and no campaign exists
the authoritative import succeeded 201, and the campaign is complete
A third state — the campaign landed and a *rebuildable* index did not — is not a
failure of the import and does not roll it back. Passages, the lexical index and
vectors are all a deterministic function of content the file carries, so losing
them costs a rebuild rather than data. It is reported on the response as a
warning, it is visible per source in the Knowledge panel, and Reindex is the
repair. Refusing a whole campaign because a search index would not build would
trade the valuable thing for the cheap one.
"""
from fastapi import Body, Depends, Request
@@ -18,15 +42,15 @@ def export_adventure(
db: Session = Depends(get_db),
adv: models.Adventure = Depends(current_adventure),
):
"""Returns a full backup: plot components, story cards, scripts, state, and tree.
"""Returns a full backup: the story, the tree, the state, and the evidence.
`app/bundle.py` owns the format, in both of its versions. A backup outlives
the schema, so no call site decides anything about its shape.
`app/bundle.py` owns the format, in all three of its versions. A backup
outlives the schema, so no call site decides anything about its shape.
"""
return bundle.export(db, adv)
@router.post("/import", response_model=schemas.AdventureOut, status_code=201)
@router.post("/import", response_model=schemas.ImportedAdventureOut, status_code=201)
def import_adventure(
request: Request,
payload: dict = Body(...),
@@ -58,18 +82,29 @@ def import_adventure(
branches=story["branches"],
)
adventure = bundle.materialize(db, payload, story, user.id)
db.commit()
try:
adventure, report = bundle.materialize(db, payload, story, user.id)
db.commit()
except Exception:
# Explicit, rather than left to the session closing. The planner has
# already refused everything it can see, so anything raising here is a
# write that surprised us — the case where leaving a partial campaign
# behind would be worst, and the case a test can only assert on if the
# rollback is a statement rather than a side effect of teardown.
db.rollback()
raise
db.refresh(adventure)
# A campaign exported while undone imports undone (M3), so the history
# controls have to be right on the response that opens it — otherwise the
# first thing the reader sees about a story with a retained future is a
# greyed-out Redo.
out = schemas.AdventureOut.model_validate(adventure)
out = schemas.ImportedAdventureOut.model_validate(adventure)
out.can_undo = head.can_undo(db, adventure)
out.can_redo = head.can_redo(db, adventure)
# This is not a funnel step. A returning player imports a bundle, so it
# says nothing about how far a first-time visitor got. It is counted anyway,
# because it is the clearest evidence that anyone uses the export format.
out.import_warnings = [
f"The search index for “{failure['title']}” could not be rebuilt "
f"({failure['detail']}). The file itself imported intact — use Reindex "
f"in the Knowledge panel to try again."
for failure in report["knowledge_index_failures"]
]
return out
+50 -11
View File
@@ -14,6 +14,8 @@ from ... import (
worldstate,
)
from ...database import get_db
from ...knowledge import embeddings as knowledge_embeddings
from ...knowledge import importer as knowledge_importer
from .deps import CurrentUser, current_adventure, router
from .paging import action_window, annotate_takes
@@ -195,20 +197,37 @@ def create_adventure(
# everywhere, which buys nothing.
tree.head_branch(db, adventure)
# M8: canon written at setup. Stored in the same document the prompt and the
# validator already read, so nothing downstream learns a second shape.
rules = [r.strip() for r in payload.canon_rules if r.strip()]
if rules:
adventure.campaign_canon = {"rules": rules}
if scenario:
for ref, spec in scenario_card_specs(scenario, values).items():
db.add(models.StoryCard(adventure_id=adventure.id, source_ref=ref, **spec))
if scenario.prompt.strip():
opening = models.Action(
adventure_id=adventure.id,
type="start",
text=fill_placeholders(scenario.prompt, values),
)
# Record the starting state on the opening node, so undoing or
# retrying the first turn has a state to roll back to.
attempts.snapshot_outcome(adventure, opening)
tree.place_action(db, adventure, opening)
db.add(opening)
# The opening scene. A scenario's prompt and M8's `opening` field are the
# same thing arriving by different routes, so they build the same node —
# the scenario wins when both are present, because it is the more specific
# request. Everything downstream (Undo to the opening, retrying the first
# turn, the drop cap) keys on the `start` type and is unchanged.
opening_text = (
fill_placeholders(scenario.prompt, values)
if scenario and scenario.prompt.strip()
else payload.opening.strip()
)
if opening_text:
opening = models.Action(
adventure_id=adventure.id,
type="start",
text=opening_text,
)
# Record the starting state on the opening node, so undoing or
# retrying the first turn has a state to roll back to.
attempts.snapshot_outcome(adventure, opening)
tree.place_action(db, adventure, opening)
db.add(opening)
db.commit()
db.refresh(adventure)
@@ -296,6 +315,20 @@ def update_adventure(
adventure: models.Adventure = Depends(current_adventure),
):
fields = payload.model_dump(exclude_unset=True)
# M8. `canon_rules` is a read-only view onto the stored `campaign_canon`
# document, so it is written by hand rather than by the setattr loop — and
# only the `rules` key is replaced. Whatever else the document holds
# (`forbidden_status_changes`, which has no browser editor) is left exactly
# as it was, so editing canon through the browser cannot silently discard
# the structured half a fixture or an import wrote.
if "canon_rules" in fields:
rules = [r.strip() for r in (fields.pop("canon_rules") or []) if r.strip()]
canon = dict(adventure.campaign_canon or {})
if rules:
canon["rules"] = rules
else:
canon.pop("rules", None)
adventure.campaign_canon = canon or None
for field, value in fields.items():
setattr(adventure, field, value)
# M6: a summary the reader typed is still a summary, so it is anchored to
@@ -317,8 +350,14 @@ def delete_adventure(
db: Session = Depends(get_db),
adventure: models.Adventure = Depends(current_adventure),
):
# M9. The lexical index first, while the chunks that locate it still exist.
# It is a virtual table, so nothing cascades into it, and an orphaned index
# row makes the *next* import into *any* campaign fail — see
# `knowledge.importer.clear_campaign_index`.
knowledge_importer.clear_campaign_index(db, adventure)
db.delete(adventure)
db.commit()
# No later request reads this adventure's vectors, so drop them now. The
# cache would otherwise hold them until the process restarted.
memorybank.forget_cached_vectors(adventure_id)
knowledge_embeddings.forget_cached(adventure_id)
+31 -1
View File
@@ -82,8 +82,35 @@ async def with_turn_lock(adventure_id: int, gen):
_active_turns.discard(adventure_id)
#: Openings that mean the reader has already written the subject of the sentence.
#:
#: Matched as whole words, longest first, so "I'm" is recognised before "I".
_FIRST_PERSON = ("i ", "i'm ", "i've ", "i'll ", "i'd ", "my ", "we ", "we're ")
def format_player_input(action_type: str, text: str) -> str:
"""Formats player input the way AI Dungeon does."""
"""Formats player input the way AI Dungeon does — with one M8 correction.
The convention is a `>` marker and second person: typing `look around` in
the old Do mode stored `> You look around.`, which reads correctly and shows
the model whose turn it is.
**M8 broke that assumption and this repairs it.** `BROWSER-UX-SPEC.md` §12
replaced the Do/Say/Story selector with one natural-language field, and §11
tells the reader to write sentences like *"I enter the tavern."* Prefixing
that produced `> You I enter the tavern.` — in the transcript, in the
replayed history, and therefore in the narration, where a small model
imitates it and writes "You I thank her". It was visible in the very first
browser pass of the new composer.
So the prefix is added only when the reader has *not* already written a
subject. First person is left alone; everything else keeps the old
behaviour, and the `>` marker is unchanged in every case, because that is
what actually distinguishes a player turn in the prompt.
Storage is unchanged for text that was already formatted — see
`test_take_parentage.py`, which guards against `> You > You ...`.
"""
text = text.strip()
if action_type == "say":
text = text.strip('"')
@@ -95,6 +122,9 @@ def format_player_input(action_type: str, text: str) -> str:
text = text[4:]
if text and text[-1] not in ".!?…":
text += "."
lowered = text.lower()
if any(lowered.startswith(opening) for opening in _FIRST_PERSON):
return f"> {text}"
return f"> You {text}"
return text # The "story" type is appended as raw text.
+75
View File
@@ -0,0 +1,75 @@
"""M9: taking a verified copy of the whole database, from the browser.
Two endpoints and no third. `app/backup.py` owns the procedure and every
guarantee it makes; these only decide who may ask.
## Why there is no restore endpoint, and no download
**Restore** means replacing the database file the running process has open.
Doing that from inside that process is how someone loses both copies at once:
the connection pool still holds handles on the old file, the WAL belongs to the
old file, and a half-swapped database is not something a running application can
notice. The supported procedure is in `DEVELOPMENT.md` — stop the application,
move the file into place, start it — and it is a procedure precisely because
each step needs the application not to be running. Campaign-level recovery, the
common case and the only one that crosses machines, is the export bundle.
**Download** is not offered either. The file is a copy of every campaign on the
machine, and streaming it through the browser would put it in the download
directory, in the browser's own cache, and in whatever the reader does with it
next — for a local single-user application whose whole premise is that the story
does not leave the machine, that is a worse default than a path the reader can
copy. So the response names the directory and the reader takes it from there.
## Where the file goes
Nowhere a request can name. The destination is derived from the database the
application is already using, and the filename is generated from the clock. No
part of either comes from the caller, so there is no traversal to attempt (H08),
and the endpoints below accept no body at all.
"""
import logging
from fastapi import APIRouter, Depends, HTTPException
from .. import auth, backup, models
router = APIRouter(prefix="/api/backups", tags=["backups"])
log = logging.getLogger(__name__)
@router.get("")
def list_backups(_user: models.User = Depends(auth.get_current_user)):
"""The backups already on disk, newest first, and where they are.
The directory is reported once here rather than on every row, because it is
the same for all of them and it is what the reader needs in order to find
the files at all.
"""
return {
"directory": str(backup.directory()),
"backups": backup.existing(),
}
@router.post("", status_code=201)
def create_backup(_user: models.User = Depends(auth.get_current_user)):
"""Takes one verified backup, and reports what it wrote.
Synchronous. A backup of a local single-user database is a page copy that
finishes in well under a second, and a reader who pressed the button is
entitled to be told whether it worked rather than to be told it started.
A failure is a 500 carrying the reason. There is nothing for the caller to
fix by retrying differently — the request has no parameters — so the useful
thing is the message, and `backup.create` guarantees that the source database
is untouched and no partial file is left behind.
"""
try:
result = backup.create()
except backup.BackupError as exc:
log.error("Backup failed: %s", exc)
raise HTTPException(500, str(exc)) from exc
return {"directory": str(result.path.parent), **result.as_dict()}
+44
View File
@@ -143,6 +143,24 @@ class ScenarioListItem(ORMModel):
class AdventureCreate(BaseModel):
scenario_id: int | None = None
title: Name | None = None
# M8: the opening scene, for a campaign started without a scenario.
#
# A scenario's `prompt` already becomes the campaign's `start` action, and
# this is the same thing said directly. It exists because M8's setup flow
# creates a campaign from a form rather than from a template
# (`BROWSER-UX-SPEC.md` §41), and without it every new campaign opens on a
# blank page — the reader has to invent the situation *and* the first move
# in one box. Ignored when `scenario_id` is given, which already supplies one.
opening: Prose = ""
# M8: the campaign's own rules, as a list of sentences.
#
# The column has existed since migration 82 and both the prompt
# (`context/builder._canon_section`) and the state validator
# (`narrative/apply`) already read it — it simply had no way in from the
# browser, so a fixture had to write it with SQL. This is the highest
# authority in the campaign, which is exactly why a person setting one up
# needs to be able to state it.
canon_rules: list[Name] = []
# The `${Placeholder}` values collected from the player at the start, which
# is the AI Dungeon behavior.
placeholders: dict[str, str] = {}
@@ -165,6 +183,10 @@ class AdventureUpdate(BaseModel):
persona_name: PersonaName | None = None
persona_pronouns: PersonaPronouns | None = None
persona_desc: Prose | None = None
# M8. See `AdventureCreate.canon_rules`. Editable after setup because canon
# is the thing a reader most often gets wrong first and needs to correct —
# "resurrection is impossible" is easier to write once the story has tried it.
canon_rules: list[Name] | None = None
class AdventureRefresh(BaseModel):
@@ -456,6 +478,9 @@ class AdventureOut(ORMModel):
persona_name: str
persona_pronouns: str
persona_desc: str
# M8. Read from the `canon_rules` property on the model, which pulls the
# sentence list out of the stored `campaign_canon` document.
canon_rules: list[str] = []
created_at: datetime
updated_at: datetime
story_cards: list[StoryCardOut] = []
@@ -472,6 +497,25 @@ class AdventureOut(ORMModel):
can_redo: bool = False
class ImportedAdventureOut(AdventureOut):
"""A campaign that has just been restored from a bundle (M9).
Exactly `AdventureOut` plus what could not be rebuilt. The extra field is on
a subclass rather than on the base, because "which of your search indexes
failed to rebuild" is a fact about one import and not a property of a
campaign — putting it on `AdventureOut` would attach it to every read of
every campaign forever.
An empty list is the ordinary answer and means the whole campaign, its
evidence and its derived indexes all landed. A non-empty one means the
authoritative import succeeded and a rebuildable index did not, which is a
distinction M9 requires a caller to be able to draw: the campaign is intact,
and Reindex is the repair.
"""
import_warnings: list[str] = []
class ActionPage(BaseModel):
"""A slice of the story, counted back from the newest action."""
+3 -1
View File
@@ -63,7 +63,9 @@ def give(db: Session, user: models.User) -> models.Adventure | None:
# flush whatever part of the adventure the session still held.
with db.begin_nested():
story = bundle.plan(payload, bundle.check_format(payload))
adventure = bundle.materialize(db, payload, story, user.id)
# The starter ships with no imported knowledge, so the derived
# report is always empty here and nothing reads it.
adventure, _ = bundle.materialize(db, payload, story, user.id)
_link_scenario(db, adventure, payload)
return adventure
except Exception:
+406
View File
@@ -0,0 +1,406 @@
"""M9: one campaign that exercises every portable data family at once.
`TEST-CAMPAIGN-FIXTURE.md` describes the standard Continuity Test campaign, and
the acceptance suites use it. This is a different thing and does not replace it:
the Continuity Test is shaped to read like a story, and this one is shaped to
break a round trip. Every property M9 promises has a source in this campaign that
would be silently lost by a plausible mistake in the exporter or the importer.
Opening
|
+-- normal turns transcript, state events, snapshots
+-- Retry two takes at one coordinate
+-- knowledge retrieval imported passages in a stored prompt
+-- Save Point S1 a named coordinate on the first line
+-- more turns a future the reader will leave
|
+-- Undo x2 the head steps back
|
+-- divergent continuation a second branch, and a second future
+-- Save Point S2 a named coordinate on the second line
+-- manual state correction an event nothing narrated
+-- Undo x1 the head ends behind the newest row
The shape is chosen so that no single fact identifies a position. The active head
is not the newest row, not the deepest row, not the last row written, and not on
the branch that holds the most story — an importer that guesses any one of those
lands somewhere else.
Two campaigns are built, not one. `build` returns the rich campaign; the fixture
also leaves a neighbour beside it, because a bundle that accidentally exported
another campaign's rows would otherwise export nothing and pass.
The builder speaks HTTP throughout. A fixture that wrote rows directly would
prove the exporter can read what the fixture wrote, which is not the claim.
"""
from __future__ import annotations
import asyncio
from app import memorybank
from fakes import ScriptedProvider, state_block
# --------------------------------------------------------------- source files
# Three imported sources, one per class, plus the two lifecycle states that a
# round trip most easily loses: a source someone switched off, and one only the
# narrator may see.
CANON_MD = """# Westhaven
## The Old Abbey
The abbey above Westhaven has stood since the founding. Its crypt is sealed,
and the seal has never been broken.
## What cannot happen here
The dead do not return. No rite, relic or bargain in Westhaven has ever
returned anyone from death, and none ever will.
"""
REFERENCE_MD = """# The Crooked Lantern
The tavern on Fen Street is timber-framed, low-beamed, and older than the
street it stands on. The hearth is never allowed to go out.
## The keeper
Mara keeps the Crooked Lantern. She was born in Westhaven and has never left
it.
"""
INSPIRATION_MD = """# Weather notes
Rain on shutters. Lantern light through wet glass. The smell of a hearth
banked for the night.
"""
SECRET_MD = """# The seal
The abbey seal was broken once, sixty years ago, and set again by a hand that
is still alive. Nobody in Westhaven knows this.
"""
DISABLED_MD = """# Discarded draft
An earlier draft of the Westhaven material, kept for reference and switched off
so it cannot reach the narrator.
"""
#: The campaign's own rule, so the correction and the canon block have something
#: real to be measured against.
CAMPAIGN_CANON = {"rules": ["The dead do not return."]}
OPENING = "Aldric sits in the Crooked Lantern with Mara, and the rain starts."
# ------------------------------------------------------------------- helpers
def _play(client, adv_id, text, prose, events=None, kind="do"):
ScriptedProvider.replies = [f"{prose}\n{state_block(events or [])}"]
response = client.post(
f"/api/adventures/{adv_id}/actions", json={"type": kind, "text": text}
)
assert response.status_code == 200, response.text[:400]
return response
def _fact(predicate, value, fact_id):
return {"type": "add_fact", "predicate": predicate, "value": value,
"fact_id": fact_id}
def upload(client, adv_id, name, body, classification, **fields):
"""Imports a file the way the browser does: multipart, and no pathname."""
data = {"classification": classification}
data.update({k: str(v).lower() if isinstance(v, bool) else str(v)
for k, v in fields.items()})
response = client.post(
f"/api/adventures/{adv_id}/knowledge",
files={"file": (name, body.encode("utf-8"), "text/markdown")},
data=data,
)
assert response.status_code == 201, response.text[:400]
return response.json()["id"]
def _checkpoint(client, adv_id, name, note=""):
response = client.post(
f"/api/adventures/{adv_id}/checkpoints", json={"name": name, "note": note}
)
assert response.status_code == 201, response.text[:400]
return response.json()
def _undo(client, adv_id, times=1):
for _ in range(times):
response = client.post(f"/api/adventures/{adv_id}/undo")
assert response.status_code == 200, response.text[:400]
def settle_derived(adv_id):
"""Runs the background memory and summary pass to completion.
The turn endpoint fires this as a fire-and-forget task, which a test client
does not wait for. Calling it directly is the same code on the same rows —
what is skipped is the scheduling, not the work — and it is what
`test_context_realistic.py` does for the same reason.
"""
asyncio.run(memorybank.run_post_turn(adv_id))
# --------------------------------------------------------------------- build
def build(client, adv_id) -> dict:
"""Plays the fixture campaign onto `adv_id`, and returns what it built.
The returned dictionary is the assertion source for every round-trip test:
it names the properties that must survive, measured from the campaign as it
stands here rather than restated as constants, so a test compares the copy
against the original instead of against a guess about the original.
"""
# Story memory and the rolling summary on, because a campaign that
# generated neither would let an exporter omit both and still pass. The
# abandoned line below gets long enough to earn its own, which is what E03
# is about after a round trip.
switched_on = client.patch(
f"/api/adventures/{adv_id}",
json={"auto_summarize": True, "memory_bank_enabled": True},
)
assert switched_on.status_code == 200, switched_on.text[:400]
sources = {
"canon": upload(client, adv_id, "canon.md", CANON_MD, "canon",
always_include=True),
"reference": upload(client, adv_id, "reference.md", REFERENCE_MD,
"reference"),
"inspiration": upload(client, adv_id, "inspiration.md", INSPIRATION_MD,
"inspiration"),
"secret": upload(client, adv_id, "secret.md", SECRET_MD, "canon",
visibility="hidden"),
"disabled": upload(client, adv_id, "draft.md", DISABLED_MD, "reference"),
}
disable = client.patch(
f"/api/adventures/{adv_id}/knowledge/{sources['disabled']}",
json={"enabled": False},
)
assert disable.status_code == 200, disable.text[:400]
# ---- the first line of story -----------------------------------------
# Turn 1 asks about the abbey, so the canon source is retrieved and the
# stored prompt for this turn holds an imported passage. That turn is the
# one the provenance tests read back after the round trip.
_play(client, adv_id, "ask Mara about the abbey",
"Mara sets down the cloth. The abbey, she says, is sealed.",
[_fact("tally", 10, "tally-10")])
_play(client, adv_id, "walk up to the abbey",
"The path climbs out of the town and the rain follows.",
[_fact("tally", 20, "tally-20")])
# A retry, so one coordinate holds two takes and the earlier one is
# retained but not selected.
ScriptedProvider.replies = [
"The door is oak, and the seal on it is unbroken.\n"
+ state_block([_fact("tally", 30, "tally-30")])
]
_play(client, adv_id, "try the crypt door",
"The door will not move.", [_fact("tally", 30, "tally-30")])
retry = client.post(f"/api/adventures/{adv_id}/retry")
assert retry.status_code == 200, retry.text[:400]
s1 = _checkpoint(client, adv_id, "At the crypt door",
"Before anything is decided.")
# The future the reader is about to leave behind. It is played out far
# enough to earn derived data of its own — `memorybank.MEMORY_INTERVAL` is
# six actions — because a summary and a memory belonging to an abandoned
# line are what E03 forbids reaching an active prompt, and a round trip is
# a new way to leak one.
_play(client, adv_id, "force the door",
"The seal gives, and the stair below is dark.",
[_fact("tally", 40, "tally-40")])
_play(client, adv_id, "go down",
"The crypt is dry, and the air has not moved in years.",
[_fact("tally", 50, "tally-50")])
_play(client, adv_id, "read the names on the slabs",
"Sixty years of Westhaven dead, and one slab with no name at all.",
[_fact("tally", 60, "tally-60")])
_play(client, adv_id, "touch the nameless slab",
"The stone is warm, which stone in a crypt is not.",
[_fact("tally", 70, "tally-70")])
# Derived data for the line that is about to be abandoned, written while
# the head is still on it. This is the summary and the memory that must
# come back after a round trip and must still be ineligible there.
settle_derived(adv_id)
tip_state = client.get(f"/api/adventures/{adv_id}/state").json()
# ---- step back, and go somewhere else ---------------------------------
_undo(client, adv_id, 4)
_play(client, adv_id, "turn back and return to the tavern",
"The rain has not let up, and the Lantern's windows are lit.",
[_fact("tally", 41, "tally-41")])
s2 = _checkpoint(client, adv_id, "Back at the Lantern", "The other way.")
_play(client, adv_id, "ask Mara what she is not saying",
"She looks at the fire for a while before she answers.",
[_fact("tally", 51, "tally-51")])
_play(client, adv_id, "wait",
"The rain fills the silence, and then she starts talking.",
[_fact("tally", 61, "tally-61")])
# A manual correction: an accepted state change with no narration behind
# it, which is the one kind of state event a replay could never recreate.
correction = client.post(
f"/api/adventures/{adv_id}/state/corrections",
json={
"events": [{
"type": "add_fact",
"predicate": "keeper_of_the_lantern",
"value": "Mara",
"fact_id": "keeper",
}],
"note": "Established in play before the state system saw it.",
},
)
assert correction.status_code == 201, correction.text[:400]
# Derived data for the line the reader stayed on, so the copy has both an
# eligible and an ineligible summary to tell apart. The generated one landed
# on the abandoned line, which is the E03 case; this one is typed at the
# current head, so it is the eligible case beside it. A round trip has to
# keep them on opposite sides of that line.
settle_derived(adv_id)
# One more Undo, so the head finishes behind the retained tip of its own
# branch as well as behind the abandoned line's.
_undo(client, adv_id, 1)
# Typed at the final head, so it is the eligible summary and the generated
# one on the abandoned line is not. A round trip has to keep them on
# opposite sides of that line.
typed = client.patch(
f"/api/adventures/{adv_id}",
json={"story_summary": "Aldric went back to the Lantern instead."},
)
assert typed.status_code == 200, typed.text[:400]
return snapshot_of(client, adv_id, sources=sources, s1=s1, s2=s2,
tip_state=tip_state)
def snapshot_in(action: dict) -> dict | None:
"""The stored prompt in one bundle entry, decoded.
The export compresses it (`bundle._packed`), so a test that reached for a
plain dict would conclude the evidence was missing when it is merely
encoded. Both keys are read, plain first, exactly as the importer does.
"""
from app import bundle
plain = action.get("contextSnapshot")
if isinstance(plain, dict):
return plain
return bundle._unpacked(action.get("contextSnapshotZ"))
def with_snapshot(action: dict, snapshot: dict | None) -> dict:
"""A bundle entry carrying `snapshot`, written in the plain form.
Tests that break a snapshot on purpose write the readable key, because the
importer prefers it and because a test that had to compress its own fixture
would be testing the encoding rather than the thing it edited.
"""
edited = {k: v for k, v in action.items() if k != "contextSnapshotZ"}
if snapshot is None:
edited.pop("contextSnapshot", None)
else:
edited["contextSnapshot"] = snapshot
return edited
# ------------------------------------------------------------------- reading
def snapshot_of(client, adv_id, *, sources=None, s1=None, s2=None,
tip_state=None) -> dict:
"""Everything about a campaign that a round trip has to reproduce.
Read through the API, so the comparison is between what a reader can see in
the source campaign and what a reader can see in the copy. Two campaigns
that agree here agree on everything the product promises about a restored
campaign; nothing below is a database id, because ids are expected to
differ.
"""
head = client.get(f"/api/adventures/{adv_id}").json()
branches = client.get(f"/api/adventures/{adv_id}/branches").json()
checkpoints = client.get(f"/api/adventures/{adv_id}/checkpoints").json()
knowledge = client.get(f"/api/adventures/{adv_id}/knowledge").json()
state = client.get(f"/api/adventures/{adv_id}/state").json()
events = client.get(f"/api/adventures/{adv_id}/state/events?limit=500").json()
memories = client.get(f"/api/adventures/{adv_id}/memories").json()
derived = client.get(f"/api/adventures/{adv_id}/derived").json()
return {
"id": adv_id,
"title": head["title"],
"canon_rules": head.get("canon_rules") or [],
"can_undo": head.get("can_undo"),
"can_redo": head.get("can_redo"),
"transcript": [(a["type"], a["text"]) for a in head["actions"]],
# Every branch's own story, which is the whole retained tree as text.
"branch_count": len(branches),
"checkpoints": sorted(
(c["name"], c["note"]) for c in checkpoints
),
"knowledge": sorted(
(k["title"], k["classification"], k["enabled"], k["visibility"],
k["always_include"], k["content_hash"])
for k in knowledge
),
"state": _comparable_state(state),
"state_events": sorted(
(e["event_type"], e["source"], _payload_key(e["payload"]))
for e in events
),
"memories": sorted(m["text"] for m in memories),
"summaries": sorted(
(s["preview"], s["trigger"], s["eligible"])
for s in derived.get("summaries", [])
),
# Carried through from `build`, for the tests that need the original
# ids or the state at a position the head has since left.
"sources": sources,
"s1": s1,
"s2": s2,
"tip_state": _comparable_state(tip_state) if tip_state else None,
}
def _comparable_state(state: dict) -> dict:
"""The authoritative state, with only what a reader is shown.
Groups arrive from the API as display sections, which is the right shape to
compare: two campaigns whose State panels read identically hold the same
state, whatever ids sit underneath.
"""
groups = state.get("groups") if isinstance(state, dict) else None
if not isinstance(groups, list):
return {}
return {
str(group.get("title")): sorted(
", ".join(f"{k}={group_row[k]}" for k in sorted(group_row))
for group_row in (group.get("rows") or [])
if isinstance(group_row, dict)
)
for group in groups
}
def _payload_key(payload) -> str:
"""A stable identity for an event payload, for set comparison."""
if not isinstance(payload, dict):
return str(payload)
for key in ("fact_id", "entity_id", "thread_id", "id", "predicate"):
if payload.get(key):
return f"{key}={payload[key]}"
return ",".join(f"{k}={payload[k]}" for k in sorted(payload))
+22 -3
View File
@@ -568,10 +568,29 @@ def test_the_action_cap_counts_the_rows_a_v1_file_expands_into(client, monkeypat
assert _adventure_count() == before, "and nothing was written"
def test_an_unknown_format_is_refused(client):
r = _import(client, {"format": "ai-dnd-adventure-v3", "title": "From the future"})
def test_a_format_from_a_later_build_is_refused(client):
"""A version this build has never heard of is refused, not guessed at.
The placeholder version here has to stay ahead of `bundle.FORMAT`. It was
`v3` until M9 made v3 real, at which point this test started importing a
bundle it meant to reject — the failure mode a hard-coded "next version"
always eventually has, and the reason the message is asserted against
`bundle.FORMAT` rather than against a literal.
"""
r = _import(client, {"format": "ai-dnd-adventure-v99", "title": "From the future"})
assert r.status_code == 400, r.text
assert bundle.FORMAT in r.json()["detail"]
detail = r.json()["detail"]
assert bundle.FORMAT in detail
assert "ai-dnd-adventure-v99" in detail
def test_something_that_is_not_an_export_at_all_is_refused(client):
r = _import(client, {"title": "A file of some other kind"})
assert r.status_code == 400, r.text
# Every version it can read is named, so the reader can tell whether the
# file they have is one of them.
for readable in bundle.READABLE:
assert readable in r.json()["detail"]
# ------------------------------------------------------- the persona (Phase 18)
+54 -14
View File
@@ -1476,18 +1476,27 @@ def test_h06_h07_imported_active_content_is_served_as_inert_text(client):
assert report_response.headers["x-content-type-options"] == "nosniff"
# And the browser side never renders it as HTML. Asserted against the source
# of the two components that display imported text, because that is where
# the property would be lost — a `dangerouslySetInnerHTML` added to either
# is what turns every assertion above into decoration. A real browser
# exercises the same two components; this fails at build time instead of
# waiting for someone to run one.
# of every component that displays imported or narrator text, because that
# is where the property would be lost — one `dangerouslySetInnerHTML` turns
# every assertion above into decoration.
#
# M8 renamed the Insights panel to `ContextPanel.jsx` and added
# `markdown.jsx`, which renders narrator prose. The renderer is the newest
# and largest way this property could be lost, so it is guarded here too;
# `frontend/src/markdown.test.jsx` covers its behaviour, and this covers the
# one line that would make that behaviour irrelevant.
import pathlib
frontend = pathlib.Path(__file__).resolve().parents[2] / "frontend" / "src"
for name in ("pages/Play/panels/KnowledgePanel.jsx",
"pages/Play/panels/InsightsPanel.jsx"):
"pages/Play/panels/ContextPanel.jsx",
"markdown.jsx"):
text = (frontend / name).read_text()
assert "dangerouslySetInnerHTML" not in text, name
# The prop as it would actually be written, not the bare word: these
# files discuss the hazard in their own comments, and a test that
# cannot tell an explanation from a use would forbid documenting it.
assert "dangerouslySetInnerHTML=" not in text, name
assert "dangerouslySetInnerHTML:" not in text, name
assert "innerHTML" not in text.replace("document.body.innerHTML='owned'", ""), name
@@ -1651,7 +1660,22 @@ def test_an_edited_content_hash_is_recomputed_and_reported(client):
def test_historical_prompt_evidence_survives_an_export_round_trip(client):
"""§33: the round trip does not turn provenance into dangling ids."""
"""§33: the round trip does not turn provenance into dangling ids.
Written in M7 and rewritten in M9, and the rewrite is the point of it.
In M7 the bundle carried no context snapshots at all, so this test pinned
the *absence*: there were no ids to dangle because there was no evidence,
and the imported campaign's turns simply had no snapshot. That was recorded
at the time as a limit owned by M9 rather than as a property worth keeping —
`V1-ACCEPTANCE-TESTS.md` I05 said so in as many words, and the M8 report
made it handoff question B.
M9 answered it: the evidence travels. So the assertion inverts, and what it
now pins is the thing M7 was worried about and could not check — that the
provenance arriving on the other side names *this* campaign's sources rather
than the ids it had on the machine that wrote the file.
"""
ids = import_fixture(client)
play(client, "Aldric asks about the Old Abbey and its broken-circle symbol.")
actions = client.get(f"/api/adventures/{client.adv_id}/actions").json()["actions"]
@@ -1664,17 +1688,33 @@ def test_historical_prompt_evidence_survives_an_export_round_trip(client):
bundle = client.get(f"/api/adventures/{client.adv_id}/export").json()
restored = client.post("/api/adventures/import", json=bundle).json()
# The bundle carries no context snapshots at all — it never has, by the rule
# at the top of `bundle.py` — so there are no ids to dangle. The imported
# campaign's turns simply have no snapshot, which is what a pre-M7 bundle
# already did for every other component of the inspector.
new_actions = client.get(
f"/api/adventures/{restored['id']}/actions"
).json()["actions"]
new_ai = next(a for a in reversed(new_actions) if a["type"] == "ai")
assert client.get(
moved = client.get(
f"/api/adventures/{restored['id']}/actions/{new_ai['id']}/context"
).status_code == 404
)
assert moved.status_code == 200, moved.text[:300]
moved = moved.json()
# The evidence itself is identical: the same passages, the same text, the
# same prompt the turn was actually assembled from.
assert [(r["title"], r["text"]) for r in moved["knowledge"]["used"]] == \
[(r["title"], r["text"]) for r in before["knowledge"]["used"]]
assert moved["prompt"] == before["prompt"]
# And the one pointer that is not evidence has been translated, so the
# inspector's "open this source" reaches the restored library rather than
# whatever holds that id here.
theirs = {
source["id"] for source in
client.get(f"/api/adventures/{restored['id']}/knowledge").json()
}
named = {r["source_id"] for r in moved["knowledge"]["used"]
if r["source_id"] is not None}
assert named and named <= theirs
# And the original campaign's evidence is untouched by having been exported.
after = client.get(
f"/api/adventures/{client.adv_id}/actions/{ai_action['id']}/context"
+202
View File
@@ -0,0 +1,202 @@
"""M8: the two fields the streamlined setup flow added, and what they must not do.
`BROWSER-UX-SPEC.md` §41 replaced "pick a scenario, then fill in its
placeholders" with a form. Two things had to reach the API for that to work, and
both are narrow by design (`BUILD-MILESTONES.md` M8, §39 of the brief):
opening the campaign's first scene, so a new campaign does not open on
a blank page. It builds the same `start` node a scenario's
prompt does, by the same code path.
canon_rules a read/write view onto the `rules` list inside the existing
`campaign_canon` document, which has had no API at all since
the column was added in migration 82.
Neither adds a column. `test_knowledge_migration.py` and the M8 report's
migration proof cover the schema claim; these cover the behaviour.
"""
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
OPENING = "You sit at a shared table in the Crooked Lantern Tavern."
@pytest.fixture()
def client(monkeypatch):
"""The suite's convention: create the schema, own a user, drop it after.
The first version of this fixture just wrapped `TestClient(app)`. It passed
in isolation and failed ten ways in the full suite, because the tests share
one database and every other module creates and drops the schema around
itself — so this file inherited whatever the previous module had left, and
had no user of its own for `auth.get_current_user` to find.
"""
Base.metadata.create_all(bind=engine)
setup = SessionLocal()
user = models.User(is_guest=False, email="m8setup@example.com")
setup.add(user)
setup.flush()
setup.add(models.Settings(user_id=user.id, model="test-model"))
setup.commit()
user_id = user.id
setup.close()
monkeypatch.setattr(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)
try:
yield c
finally:
app.dependency_overrides.clear()
Base.metadata.drop_all(bind=engine)
def _campaign(client, **body):
r = client.post("/api/adventures", json=body)
assert r.status_code == 201, r.text
return r.json()
# ---------------------------------------------------------------- opening ---
def test_the_opening_becomes_the_campaigns_first_scene(client):
adv = _campaign(client, title="With opening", opening=OPENING)
assert [(a["type"], a["text"]) for a in adv["actions"]] == [("start", OPENING)]
# `action_count` is computed on read, so the create response reports 0 —
# for a scenario-made campaign too, and it has always done so. The browser
# navigates to the campaign and re-reads, which is the surface asserted
# here and the one a reader actually sees.
fetched = client.get(f"/api/adventures/{adv['id']}").json()
assert fetched["action_count"] == 1
assert [(a["type"], a["text"]) for a in fetched["actions"]] == [("start", OPENING)]
def test_the_opening_is_not_duplicated(client):
adv = _campaign(client, title="Once", opening=OPENING)
again = client.get(f"/api/adventures/{adv['id']}").json()
assert [a["text"] for a in again["actions"]].count(OPENING) == 1
assert again["action_count"] == 1
def test_the_opening_is_placed_on_the_tree_like_any_other_node(client):
"""It must not bypass head/history semantics.
A `start` node that was not placed on the tree, or carried no state
snapshot, would break Undo and retry at the first turn — which is exactly
where a new reader meets them.
"""
adv = _campaign(client, title="Placed", opening=OPENING)
db = SessionLocal()
try:
row = (db.query(models.Action)
.filter(models.Action.adventure_id == adv["id"]).one())
assert row.depth == 0
assert row.branch_id is not None
assert row.parent_id is None
finally:
db.close()
# And the head is at it: there is nothing before the opening to undo to.
assert adv["can_undo"] is False
assert adv["can_redo"] is False
assert client.post(f"/api/adventures/{adv['id']}/undo").status_code >= 400
def test_a_campaign_without_an_opening_still_starts_empty(client):
adv = _campaign(client, title="Blank")
assert adv["actions"] == []
def test_a_scenario_prompt_takes_precedence_and_is_never_doubled(client):
"""Both routes build the same node, so only one of them may fire."""
sc = client.post("/api/scenarios",
json={"title": "S", "prompt": "A scenario opening."}).json()
adv = _campaign(client, scenario_id=sc["id"], opening=OPENING)
assert [a["text"] for a in adv["actions"]] == ["A scenario opening."]
legacy = _campaign(client, scenario_id=sc["id"])
assert [a["text"] for a in legacy["actions"]] == ["A scenario opening."]
def test_the_opening_survives_export_and_import(client):
adv = _campaign(client, title="Round trip", opening=OPENING)
bundle = client.get(f"/api/adventures/{adv['id']}/export").json()
restored = client.post("/api/adventures/import", json=bundle).json()
assert [a["text"] for a in restored["actions"]] == [OPENING]
# ------------------------------------------------------------ canon_rules ---
def test_canon_rules_round_trip_and_blank_lines_are_dropped(client):
adv = _campaign(client, title="Canon",
canon_rules=["Resurrection is impossible.", " ", "Magic exists."])
assert adv["canon_rules"] == ["Resurrection is impossible.", "Magic exists."]
patched = client.patch(f"/api/adventures/{adv['id']}",
json={"canon_rules": ["Only one rule now."]}).json()
assert patched["canon_rules"] == ["Only one rule now."]
cleared = client.patch(f"/api/adventures/{adv['id']}",
json={"canon_rules": []}).json()
assert cleared["canon_rules"] == []
def test_editing_canon_preserves_the_structured_half_it_has_no_editor_for(client):
"""`campaign_canon` also holds `forbidden_status_changes`.
The browser edits sentences and has no editor for the structured shape, so
writing the sentences must not discard it — otherwise importing a bundle
that carries one and then touching canon in the UI would silently drop a
rule the validator enforces.
"""
adv = _campaign(client, title="Structured")
db = SessionLocal()
try:
row = db.get(models.Adventure, adv["id"])
row.campaign_canon = {
"rules": ["R1"],
"forbidden_status_changes": [{"from": "dead", "to": "alive"}],
}
db.commit()
finally:
db.close()
assert client.get(f"/api/adventures/{adv['id']}").json()["canon_rules"] == ["R1"]
client.patch(f"/api/adventures/{adv['id']}", json={"canon_rules": ["R2", "R3"]})
db = SessionLocal()
try:
stored = db.get(models.Adventure, adv["id"]).campaign_canon
assert stored["rules"] == ["R2", "R3"]
assert stored["forbidden_status_changes"] == [{"from": "dead", "to": "alive"}]
finally:
db.close()
def test_canon_is_untouched_by_an_unrelated_patch(client):
adv = _campaign(client, title="Untouched", canon_rules=["A rule."])
renamed = client.patch(f"/api/adventures/{adv['id']}",
json={"title": "Renamed"}).json()
assert renamed["canon_rules"] == ["A rule."]
assert renamed["title"] == "Renamed"
def test_canon_reaches_the_prompt_as_the_campaigns_own_rules(client):
"""The point of exposing it: what is written here is what the narrator is told."""
adv = _campaign(client, title="Prompted",
canon_rules=["Resurrection is impossible."])
report = client.get(f"/api/adventures/{adv['id']}/context").json()
canon = next((s["text"] for s in report["sections"]
if s["label"] == "campaign_canon"), "")
assert "Resurrection is impossible." in canon
+451
View File
@@ -0,0 +1,451 @@
"""M9: a consistent copy of the whole database, taken while it is being written.
`app/backup.py` explains why a plain file copy is not a backup. This file is the
evidence for the claim, and the shape of it matters: **every test below opens the
backup as its own database and reads what is in it.** A test that only checked a
file appeared, or that the endpoint returned 201, would pass against a `cp` — and
a `cp` is exactly what this replaces.
The load test is the one that separates the two. It writes to the source
database *while* the backup is being taken, from a second thread, and then asks
the copy for a story it can check turn by turn. A page-torn copy would show a
transcript with a hole in it, a campaign whose head points past its own story, or
a `quick_check` failure — and would show none of those on a quiet database, which
is why the quiet case is not the interesting one.
python -m pytest tests/test_m9_backup.py -v
"""
import os
import sqlite3
import tempfile
import threading
import time
from pathlib import Path
import pytest
from fastapi import Depends
from fastapi.testclient import TestClient
from app import auth, backup, limits, models
from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.routers import adventures
from fakes import ScriptedProvider, tally_of, tally_reply
@pytest.fixture()
def client(monkeypatch):
"""The app, and a campaign with enough in it to recognise afterwards."""
Base.metadata.create_all(bind=engine)
setup = SessionLocal()
user = models.User(is_guest=False, email="backup@example.com")
setup.add(user)
setup.flush()
setup.add(models.Settings(user_id=user.id, model="test-model"))
adventure = models.Adventure(user_id=user.id, title="Backed up")
setup.add(adventure)
setup.flush()
setup.add(models.Action(
adventure_id=adventure.id, type="start", text="The story opens.",
))
setup.commit()
adv_id, user_id = adventure.id, user.id
setup.close()
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 = adv_id
try:
yield test_client
finally:
app.dependency_overrides.clear()
adventures.turns._active_turns.clear()
Base.metadata.drop_all(bind=engine)
@pytest.fixture()
def elsewhere(tmp_path, monkeypatch):
"""Backups land under a temporary directory, not beside the real database."""
fake_db = tmp_path / "campaign.db"
fake_db.write_bytes(Path(str(engine.url.database)).read_bytes())
return fake_db
def _play(client, text, total):
ScriptedProvider.replies = [tally_reply(f"Beat {total // 10}.", total)]
response = client.post(f"/api/adventures/{client.adv_id}/actions",
json={"type": "do", "text": text})
assert response.status_code == 200, response.text[:300]
def _open(path) -> sqlite3.Connection:
"""The backup, as its own database, read-only."""
connection = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
connection.row_factory = sqlite3.Row
return connection
# ------------------------------------------------------------------ the copy
def test_the_backup_is_a_database_that_passes_its_own_integrity_check(client):
for turn in range(1, 4):
_play(client, f"turn {turn}", turn * 10)
result = backup.create()
try:
assert result.integrity == "ok"
assert result.pages > 0
assert result.bytes > 0
with _open(result.path) as db:
assert db.execute("PRAGMA quick_check").fetchone()[0] == "ok"
assert db.execute("PRAGMA foreign_key_check").fetchall() == []
finally:
result.path.unlink(missing_ok=True)
def test_the_backup_holds_the_schema_and_every_family_of_row(client):
"""Not "the file exists": the copy is opened and asked what is in it."""
for turn in range(1, 4):
_play(client, f"turn {turn}", turn * 10)
checkpoint = client.post(f"/api/adventures/{client.adv_id}/checkpoints",
json={"name": "Here", "note": "A position."})
assert checkpoint.status_code == 201
upload = client.post(
f"/api/adventures/{client.adv_id}/knowledge",
files={"file": ("canon.md", b"# Rule\n\nThe dead do not return.\n",
"text/markdown")},
data={"classification": "canon"},
)
assert upload.status_code == 201, upload.text[:300]
result = backup.create()
try:
with _open(result.path) as db:
tables = {
row["name"] for row in
db.execute("SELECT name FROM sqlite_master WHERE type='table'")
}
for expected in ("adventures", "actions", "branches", "checkpoints",
"knowledge_sources", "knowledge_chunks",
"state_events", "summaries", "settings"):
assert expected in tables, f"{expected} is missing from the backup"
campaign = db.execute(
"SELECT * FROM adventures WHERE id = ?", (client.adv_id,)
).fetchone()
assert campaign["title"] == "Backed up"
# The head, which is the thing a restore has to reproduce.
assert campaign["head_depth"] >= 0
assert campaign["head_branch_id"] is not None
texts = [row["text"] for row in db.execute(
"SELECT text FROM actions WHERE adventure_id = ? ORDER BY id",
(client.adv_id,),
)]
assert "The story opens." in texts
assert any("Beat 3." in text for text in texts)
assert db.execute(
"SELECT name FROM checkpoints WHERE adventure_id = ?",
(client.adv_id,),
).fetchone()["name"] == "Here"
assert db.execute(
"SELECT COUNT(*) c FROM knowledge_sources WHERE adventure_id = ?",
(client.adv_id,),
).fetchone()["c"] == 1
assert db.execute(
"SELECT COUNT(*) c FROM state_events WHERE adventure_id = ?",
(client.adv_id,),
).fetchone()["c"] > 0
# And the head names a turn that is actually in the copy.
assert db.execute(
"SELECT COUNT(*) c FROM actions WHERE adventure_id = ? "
"AND branch_id = ? AND depth = ?",
(client.adv_id, campaign["head_branch_id"], campaign["head_depth"]),
).fetchone()["c"] > 0
finally:
result.path.unlink(missing_ok=True)
def test_the_state_in_the_backup_is_the_state_the_campaign_had(client):
"""The authoritative document, read out of the copy and compared."""
for turn in range(1, 5):
_play(client, f"turn {turn}", turn * 10)
live = client.get(f"/api/adventures/{client.adv_id}/state").json()["document"]
result = backup.create()
try:
with _open(result.path) as db:
from app import compression
blob = db.execute(
"SELECT narrative_state FROM adventures WHERE id = ?",
(client.adv_id,),
).fetchone()["narrative_state"]
assert tally_of(compression.unpack(blob)) == tally_of(live) == 40
finally:
result.path.unlink(missing_ok=True)
# ------------------------------------------------------------ while it is live
def test_a_backup_taken_during_writes_is_consistent(client):
"""The claim a plain file copy cannot make.
Turns are played from a second thread throughout the copy. The backup that
comes out is a snapshot of *some* committed point — which point is not
determined, and asserting on a particular one would be asserting on a race —
so what is checked is that it is a coherent one: `quick_check` passes, no
foreign key dangles, the transcript has no gap in it, and the head names a
turn that exists.
"""
stop = threading.Event()
written: list[int] = []
failures: list[Exception] = []
def keep_writing():
turn = 0
while not stop.is_set() and turn < 40:
turn += 1
try:
_play(client, f"concurrent {turn}", turn * 10)
written.append(turn)
except Exception as exc: # noqa: BLE001 - reported to the test
failures.append(exc)
return
time.sleep(0.005)
writer = threading.Thread(target=keep_writing, daemon=True)
writer.start()
# Let a few turns land, so the copy is taken over a database that is moving
# rather than one that has not started.
while len(written) < 3 and writer.is_alive():
time.sleep(0.01)
result = backup.create()
stop.set()
writer.join(timeout=30)
assert not failures, f"the writer failed: {failures[0]}"
assert written, "no turn was written during the backup"
try:
with _open(result.path) as db:
assert db.execute("PRAGMA quick_check").fetchone()[0] == "ok"
assert db.execute("PRAGMA foreign_key_check").fetchall() == []
rows = db.execute(
"SELECT depth, type FROM actions WHERE adventure_id = ? "
"AND live = 1 ORDER BY depth",
(client.adv_id,),
).fetchall()
depths = [row["depth"] for row in rows]
assert depths == list(range(len(depths))), (
f"the transcript in the backup has a gap: {depths}"
)
campaign = db.execute(
"SELECT head_branch_id, head_depth FROM adventures WHERE id = ?",
(client.adv_id,),
).fetchone()
assert db.execute(
"SELECT COUNT(*) c FROM actions WHERE adventure_id = ? "
"AND branch_id = ? AND depth = ?",
(client.adv_id, campaign["head_branch_id"], campaign["head_depth"]),
).fetchone()["c"] > 0, "the head points past the story in the backup"
finally:
result.path.unlink(missing_ok=True)
def test_the_source_database_is_untouched_by_a_backup(client):
"""Opened read-only, so this is a guarantee rather than an observation."""
_play(client, "one", 10)
source = Path(str(engine.url.database))
before = source.read_bytes()
result = backup.create()
try:
assert source.read_bytes() == before
assert client.get(f"/api/adventures/{client.adv_id}").status_code == 200
finally:
result.path.unlink(missing_ok=True)
# ---------------------------------------------------------------- the rules
def test_an_existing_backup_is_never_overwritten(client):
"""Yesterday's backup surviving today's mistake is most of the point."""
first = backup.create()
second = backup.create()
try:
assert first.path != second.path
assert first.path.exists() and second.path.exists()
finally:
first.path.unlink(missing_ok=True)
second.path.unlink(missing_ok=True)
def test_two_backups_in_the_same_second_do_not_collide(client, monkeypatch):
from datetime import datetime
fixed = datetime(2026, 9, 7, 4, 30, 0)
first = backup.create(now=fixed)
second = backup.create(now=fixed)
try:
assert first.path != second.path
assert first.path.exists() and second.path.exists()
finally:
first.path.unlink(missing_ok=True)
second.path.unlink(missing_ok=True)
def test_a_failed_verification_leaves_nothing_behind(client, monkeypatch):
"""A backup nobody verified is a belief, and one that fails is not kept."""
monkeypatch.setattr(
backup, "_verify",
lambda path: (_ for _ in ()).throw(backup.BackupError("bad pages")),
)
root = backup.directory()
before = set(root.iterdir())
with pytest.raises(backup.BackupError, match="bad pages"):
backup.create()
assert set(root.iterdir()) == before, "a failed backup left a file behind"
def test_a_failed_copy_leaves_nothing_behind_and_reports_the_reason(
client, monkeypatch
):
monkeypatch.setattr(
backup, "_copy",
lambda source, working: (_ for _ in ()).throw(OSError("disk full")),
)
root = backup.directory()
before = set(root.iterdir())
with pytest.raises(backup.BackupError, match="disk full"):
backup.create()
assert set(root.iterdir()) == before
def test_a_missing_source_database_is_reported_rather_than_guessed_at(tmp_path):
with pytest.raises(backup.BackupError, match="no database"):
backup.create(tmp_path / "not-here.db")
def test_the_partial_file_is_never_left_wearing_a_backups_name(client, monkeypatch):
"""The rename is the last step, so an interrupted run is invisible."""
seen: list[Path] = []
real_copy = backup._copy
def watch(source, working):
seen.append(Path(working))
return real_copy(source, working)
monkeypatch.setattr(backup, "_copy", watch)
result = backup.create()
try:
assert seen and seen[0].name.endswith(".partial")
assert not seen[0].exists(), "the temporary file survived"
assert result.path.exists()
assert not result.path.name.endswith(".partial")
finally:
result.path.unlink(missing_ok=True)
# --------------------------------------------------------------- the endpoint
def test_the_endpoint_takes_a_backup_and_says_where_it_went(client):
response = client.post("/api/backups")
assert response.status_code == 201, response.text[:300]
body = response.json()
path = Path(body["directory"]) / body["filename"]
try:
assert body["integrity"] == "ok"
assert body["bytes"] > 0
assert path.exists()
with _open(path) as db:
assert db.execute("PRAGMA quick_check").fetchone()[0] == "ok"
finally:
path.unlink(missing_ok=True)
def test_the_endpoint_lists_what_is_there_newest_first(client):
"""Ordered by when the backup was taken, which is what its name records.
Both files here are written in the same instant, so their modification times
are indistinguishable and only the stamp in the name says which is which.
That is not a contrived case: copying a backup to another disk or restoring
one from an archive rewrites its mtime, and a list that reordered itself
afterwards would report when the file was last handled rather than when the
backup was taken.
"""
from datetime import datetime
older = backup.create(now=datetime(2026, 9, 1, 10, 0, 0))
newer = backup.create(now=datetime(2026, 9, 6, 10, 0, 0))
try:
listed = client.get("/api/backups")
assert listed.status_code == 200
rows = listed.json()["backups"]
names = [row["filename"] for row in rows]
assert names.index(newer.path.name) < names.index(older.path.name)
by_name = {row["filename"]: row["taken_at"] for row in rows}
assert by_name[newer.path.name].startswith("2026-09-06T10:00")
assert by_name[older.path.name].startswith("2026-09-01T10:00")
finally:
older.path.unlink(missing_ok=True)
newer.path.unlink(missing_ok=True)
def test_a_backup_this_build_did_not_name_still_lists(client):
"""A file in the directory whose name carries no stamp is still shown.
The modification time answers instead. The fallback exists to keep a
hand-renamed or third-party file visible rather than silently absent from
the list a reader uses to find their backups.
"""
stray = backup.directory() / f"{backup.PREFIX}-handwritten.db"
stray.write_bytes(b"SQLite format 3\x00")
try:
rows = client.get("/api/backups").json()["backups"]
listed = {row["filename"]: row for row in rows}
assert stray.name in listed
assert listed[stray.name]["taken_at"]
finally:
stray.unlink(missing_ok=True)
def test_the_endpoint_accepts_no_path_from_the_caller(client):
"""H08. There is no field to attempt a traversal in.
The destination is derived from the database the application already has
open and the name from the clock, so a body is not merely ignored — there is
nothing for one to name.
"""
from app.main import app as application
schema = application.openapi()["paths"]["/api/backups"]["post"]
assert "requestBody" not in schema
assert not schema.get("parameters")
# And sending one anyway changes nothing about where the file lands.
response = client.post("/api/backups", json={"path": "../../../tmp/escape.db"})
assert response.status_code == 201, response.text[:300]
body = response.json()
path = Path(body["directory"]) / body["filename"]
try:
assert path.parent == backup.directory()
assert ".." not in body["filename"]
finally:
path.unlink(missing_ok=True)
def test_a_failure_is_a_clear_error_rather_than_a_silent_success(
client, monkeypatch
):
monkeypatch.setattr(
backup, "create",
lambda *a, **k: (_ for _ in ()).throw(backup.BackupError("no space left")),
)
response = client.post("/api/backups")
assert response.status_code == 500
assert "no space left" in response.json()["detail"]
+460
View File
@@ -0,0 +1,460 @@
"""M9: the campaign moves to a machine that has never seen it.
This is the milestone's Definition of Done, and it is the one claim the rest of
the M9 suite cannot make. `test_m9_portability.py` imports beside the original,
in one process, against one database — which is the right place to check the
*contract* and the wrong place to check *portability*. A shared id space, a
warm cache, a row the exporter forgot to scope, a session still holding the
original: every one of those would pass there and fail here.
So each test below:
1. starts a real server process against database A, and plays a campaign;
2. exports it over HTTP and stops that process;
3. starts a **second** server process against database B, **a file that has
never existed before**, in a different directory;
4. imports the file over HTTP, and asks the second process what it has.
Nothing crosses between them but the bundle. Migrations run on B from nothing,
because it is a new file — so this is also the fresh-install path, and the
"clean data directory" in the Definition of Done is a directory, not a metaphor.
The final test restarts the *importing* server, which is L03 after a move: a
Save Point restored in the third process must reach the same position and the
same state as it did in the second.
python -m pytest tests/test_m9_clean_import.py -v
"""
import json
import os
import shutil
import sqlite3
import subprocess
import sys
import tempfile
import urllib.error
import urllib.request
from pathlib import Path
import pytest
from fakes import TALLY_PER_TURN, tally_of
from test_process_restart import Server, _free_port
HERE = Path(__file__).resolve().parent
@pytest.fixture()
def machines():
"""Two directories, each with its own database, and the servers on them.
Two directories rather than two filenames, because the backup directory and
anything else the application derives from the database's location must land
in the importing machine's own space rather than beside the exporter's.
"""
root = tempfile.mkdtemp(prefix="m9-clean-")
started: list[Server] = []
def start(name: str) -> Server:
directory = os.path.join(root, name)
os.makedirs(directory, exist_ok=True)
server = Server(os.path.join(directory, "campaign.db"), _free_port())
started.append(server)
server.wait_until_ready()
return server
def path_of(name: str) -> str:
return os.path.join(root, name, "campaign.db")
try:
yield start, path_of
finally:
for server in started:
server.stop()
shutil.rmtree(root, ignore_errors=True)
# ------------------------------------------------------------------ building
def _campaign(server: Server) -> int:
"""A campaign with everything a move has to carry, played over HTTP.
Deliberately not `m9_fixture`: that builds through a `TestClient` and this
file exists to avoid one. What it reproduces is the same shape — a retry, a
Save Point, an imported source that a turn actually used, an undone head and
a retained future.
"""
adventure = server.call("POST", "/adventures", {
"title": "Moved between machines",
"canon_rules": ["The dead do not return."],
"opening": "Aldric sits in the Crooked Lantern with Mara.",
}, expect=201)
adv_id = adventure["id"]
_upload(server, adv_id, "canon.md", "canon", (
"# Westhaven\n\n## The Old Abbey\n\nThe abbey above Westhaven has stood "
"since the founding. Its crypt is sealed, its door is oak, and the seal "
"on it has never been broken.\n"
))
_upload(server, adv_id, "secret.md", "canon", (
"# The seal\n\nIt was broken once, sixty years ago.\n"
), visibility="hidden")
disabled = _upload(server, adv_id, "draft.md", "reference", (
"# Discarded draft\n\nAn earlier version, switched off.\n"
))
server.call("PATCH", f"/adventures/{adv_id}/knowledge/{disabled}",
{"enabled": False}, expect=200)
# The spawned narrator writes "Beat N." and nothing else, so every term the
# retrieval has to work with comes from the player's own words. They are
# written to name things the Canon file names.
server.play(adv_id, "ask Mara about the abbey crypt in Westhaven")
server.play(adv_id, "walk up the hill to the abbey")
server.play(adv_id, "try the sealed crypt door of the abbey")
_retry(server, adv_id)
server.call("POST", f"/adventures/{adv_id}/checkpoints",
{"name": "At the door", "note": "Before deciding."}, expect=201)
server.play(adv_id, "force the door")
server.play(adv_id, "go down the stair")
server.call("POST", f"/adventures/{adv_id}/state/corrections", {
"events": [{"type": "add_fact", "predicate": "keeper", "value": "Mara",
"fact_id": "keeper"}],
"note": "Established in play before the state system saw it.",
}, expect=201)
# Two Undos, so the export is taken behind the retained tip.
server.call("POST", f"/adventures/{adv_id}/undo", expect=200)
server.call("POST", f"/adventures/{adv_id}/undo", expect=200)
return adv_id
def _retry(server: Server, adv_id: int) -> None:
"""Retries the newest turn, over the streaming endpoint it actually uses.
`Server.call` parses JSON, and `/retry` answers with an SSE stream as
`/actions` does — so calling it as JSON reads `data: {...}` as a document and
fails on the first character. Draining the stream is what the browser does.
"""
request = urllib.request.Request(
f"http://127.0.0.1:{server.port}/api/adventures/{adv_id}/retry",
data=b"{}", method="POST",
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(request, timeout=120) as response:
body = response.read()
assert b'"type": "error"' not in body, body[:300]
def _upload(server: Server, adv_id: int, name: str, classification: str,
body: str, **fields) -> int:
"""A multipart knowledge upload over real HTTP, without a client library."""
boundary = "----m9cleanimport"
parts = []
for key, value in {"classification": classification, **fields}.items():
parts.append(
f"--{boundary}\r\nContent-Disposition: form-data; name=\"{key}\"\r\n"
f"\r\n{value}\r\n"
)
parts.append(
f"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; "
f"filename=\"{name}\"\r\nContent-Type: text/markdown\r\n\r\n{body}\r\n"
)
payload = ("".join(parts) + f"--{boundary}--\r\n").encode()
request = urllib.request.Request(
f"http://127.0.0.1:{server.port}/api/adventures/{adv_id}/knowledge",
data=payload, method="POST",
headers={"Content-Type": f"multipart/form-data; boundary={boundary}"},
)
with urllib.request.urlopen(request, timeout=60) as response:
return json.loads(response.read())["id"]
def _snapshot(server: Server, adv_id: int) -> dict:
"""What a reader can see, read over HTTP through the API they read."""
page = server.call("GET", f"/adventures/{adv_id}", expect=200)
return {
"title": page["title"],
"canon_rules": page["canon_rules"],
"transcript": [(a["type"], a["text"]) for a in page["actions"]],
"can_undo": page["can_undo"],
"can_redo": page["can_redo"],
"state": server.call("GET", f"/adventures/{adv_id}/state", expect=200)["document"],
"checkpoints": sorted(
(c["name"], c["note"], c["depth"])
for c in server.call("GET", f"/adventures/{adv_id}/checkpoints", expect=200)
),
"knowledge": sorted(
(k["title"], k["classification"], k["enabled"], k["visibility"],
k["content_hash"], k["index_state"], k["chunk_count"] > 0)
for k in server.call("GET", f"/adventures/{adv_id}/knowledge", expect=200)
),
"events": sorted(
(e["event_type"], e["source"], json.dumps(e["payload"], sort_keys=True))
for e in server.call("GET", f"/adventures/{adv_id}/state/events?limit=500",
expect=200)
),
"rows": server.total_rows(adv_id),
}
# ------------------------------------------------------------------- the move
@pytest.fixture()
def moved(machines):
"""The campaign, exported from machine A and imported into a clean B."""
start, path_of = machines
source = start("a")
adv_id = _campaign(source)
before = _snapshot(source, adv_id)
# What the source machine retrieves at this position, recorded while it is
# still running. It is the only thing the copy can honestly be compared to.
retrieved = {
record["filename"] for record in
source.call("GET", f"/adventures/{adv_id}/context", expect=200)
["knowledge"]["used"]
}
bundle = source.call("GET", f"/adventures/{adv_id}/export", expect=200)
source.stop()
assert not os.path.exists(path_of("b")), "machine B must not exist yet"
target = start("b")
assert target.call("GET", "/adventures", expect=200) == [], \
"machine B is not empty"
imported = target.call("POST", "/adventures/import", bundle, expect=201)
return {
"bundle": bundle, "before": before, "target": target,
"retrieved": retrieved,
"copy_id": imported["id"], "imported": imported,
"path": path_of, "start": start,
}
def test_the_campaign_arrives_whole_on_a_machine_that_never_had_it(moved):
"""The Definition of Done, in one assertion per family."""
after = _snapshot(moved["target"], moved["copy_id"])
before = moved["before"]
assert after["transcript"] == before["transcript"]
assert after["state"] == before["state"]
assert after["canon_rules"] == before["canon_rules"]
assert after["checkpoints"] == before["checkpoints"]
assert after["knowledge"] == before["knowledge"]
assert after["events"] == before["events"]
assert after["rows"] == before["rows"], "the retained tree is a different size"
def test_it_opens_at_the_exact_head_it_was_exported_at(moved):
"""I07, across the boundary the acceptance test names.
The export was taken two Undos behind the tip, so a machine that opened the
campaign at its newest retained turn would show a story two turns longer
than the one that was saved.
"""
after = _snapshot(moved["target"], moved["copy_id"])
assert after["transcript"] == moved["before"]["transcript"]
assert after["can_redo"] is True, "the retained future is not reachable"
assert moved["imported"]["can_redo"] is True, (
"the response that opens the campaign says Redo is unavailable"
)
assert after["rows"] > len(after["transcript"]), (
"the retained future is not in the database"
)
def test_the_state_audit_arrives_and_still_names_its_author(moved):
"""The manual correction is still a manual correction on the new machine."""
events = moved["target"].call(
f"GET", f"/adventures/{moved['copy_id']}/state/events?limit=500", expect=200
)
manual = [e for e in events if e["source"] == "manual_correction"]
assert len(manual) == 1
assert manual[0]["payload"]["predicate"] == "keeper"
assert any(e["source"] == "accepted_story" for e in events), (
"and the story's own events are there beside it"
)
def test_the_knowledge_works_with_no_access_to_the_original_machine(moved):
"""§11. The exporting machine is stopped; nothing may reach back to it.
Its process is dead and its directory holds a database this server has never
opened. If retrieval works here, it works from the content the file carried.
The comparison is against what the *source* retrieved, recorded before that
process was killed, and the source's own result is asserted first. A test
that only checked the copy retrieved something would pass by accident on a
day the fixture happened to match, and — worse — would report a portability
failure when what had actually happened is that neither side retrieved
anything. That is M8's finding 10: assert your own precondition.
"""
assert moved["retrieved"], (
"the source campaign retrieved nothing, so this proves nothing about "
"the copy"
)
report = moved["target"].call(
"GET", f"/adventures/{moved['copy_id']}/context", expect=200
)
used = {record["filename"] for record in report["knowledge"]["used"]}
assert used == moved["retrieved"], (
f"the copy retrieved {used} where the source retrieved {moved['retrieved']}"
)
assert "draft.md" not in used, "the disabled source was re-enabled by the move"
assert "canon.md" in used
def test_a_historical_turn_still_shows_what_it_was_given(moved):
"""The M8 handoff, across the boundary that made it a handoff.
Inspect Context on an old narrator turn works on a machine that never
assembled that prompt and could not reassemble it — the sources are here but
the state, the head and the canon have all moved on since.
"""
target, copy_id = moved["target"], moved["copy_id"]
page = target.call("GET", f"/adventures/{copy_id}/actions?limit=200", expect=200)
narrator = [a for a in page["actions"] if a["type"] == "ai"]
assert narrator, "the imported campaign has no narrator turn"
inspected = 0
for action in narrator:
response = target.call(
"GET", f"/adventures/{copy_id}/actions/{action['id']}/context"
)
if response is None:
continue
assert response["prompt"]["system"], "a restored prompt is empty"
assert response["sections"], "a restored prompt has no sections"
inspected += 1
assert inspected, "no turn on the new machine can say what it was told"
def test_no_secret_and_no_path_from_the_old_machine_travelled(moved):
"""I06, and the private-detail half of it.
The bundle is checked as text, because that is what actually left the
machine — a field added to a model the exporter walks would reach the file
without any test of a column noticing.
"""
text = json.dumps(moved["bundle"])
assert "api_key" not in text
assert "11434" not in text, "an inference endpoint travelled with the campaign"
assert "/tmp/" not in text and "campaign.db" not in text, (
"a filesystem path from the exporting machine travelled"
)
def test_the_importing_machine_keeps_its_own_settings(moved):
"""§15. A campaign is not a way to reconfigure the destination.
The bundle carries per-turn model provenance, which is a record of what
happened. It does not carry the endpoint, the model or the context budget,
because those describe the machine rather than the campaign — and importing
a campaign must not silently repoint the destination's inference at the
source's.
"""
settings = moved["target"].call("GET", "/settings", expect=200)
assert settings["endpoint_url"] == "http://localhost:11434/v1", (
"the import changed the destination's inference endpoint"
)
assert settings["context_token_budget"] == 16384
def test_a_missing_model_does_not_stop_the_campaign_arriving(moved):
"""§15. The campaign and its data are portable independently of a model.
The importing server has no model configured at all — nothing has ever
written a `model` into its settings — and the import still succeeds, opens,
and shows its state. Play would fail; recovery does not.
"""
settings = moved["target"].call("GET", "/settings", expect=200)
assert settings["model"] == "", "this test needs an unconfigured destination"
after = _snapshot(moved["target"], moved["copy_id"])
assert after["transcript"] == moved["before"]["transcript"]
# ------------------------------------------------- L03, after the campaign moved
def test_l03_a_save_point_restored_on_the_new_machine_survives_its_restart(moved):
"""L03, with the move in front of it.
Restore a Save Point in the second process, record the position and the
state, kill the process, start a **third** against the same file, and ask
again. What crosses is bytes on disk.
"""
target, copy_id = moved["target"], moved["copy_id"]
points = target.call("GET", f"/adventures/{copy_id}/checkpoints", expect=200)
assert points, "the Save Point did not survive the move"
point = points[0]
assert point["resolved"] is True
target.call("POST", f"/adventures/{copy_id}/checkpoints/{point['id']}/restore",
expect=200)
restored = _snapshot(target, copy_id)
rows_before = restored["rows"]
target.stop()
assert not target.is_listening()
third = moved["start"]("b")
again = _snapshot(third, copy_id)
assert again["transcript"] == restored["transcript"]
assert again["state"] == restored["state"]
assert again["rows"] == rows_before, "restoring deleted later history"
# ------------------------------------------------------ the database it wrote
def test_the_importing_machines_database_passes_its_own_integrity_check(moved):
"""A campaign written by an import is a database SQLite is happy with."""
moved["target"].stop()
connection = sqlite3.connect(moved["path"]("b"))
try:
assert connection.execute("PRAGMA quick_check").fetchone()[0] == "ok"
assert connection.execute("PRAGMA foreign_key_check").fetchall() == []
finally:
connection.close()
def test_the_import_left_no_orphan_behind(moved):
"""§17's list, checked against the database rather than against the API.
Every one of these would be invisible from the outside until the moment it
mattered: a Save Point pointing at a turn that is not there, knowledge owned
by a campaign that does not exist, an action on a branch belonging to
something else.
"""
moved["target"].stop()
connection = sqlite3.connect(moved["path"]("b"))
try:
def one(sql):
return connection.execute(sql).fetchone()[0]
assert one("""
SELECT COUNT(*) FROM checkpoints c
LEFT JOIN actions a
ON a.branch_id = c.branch_id AND a.depth = c.depth
AND a.adventure_id = c.adventure_id
WHERE a.id IS NULL
""") == 0, "a Save Point names a position with no turn at it"
assert one("""
SELECT COUNT(*) FROM actions a
LEFT JOIN branches b ON b.id = a.branch_id
WHERE a.branch_id IS NOT NULL
AND (b.id IS NULL OR b.adventure_id <> a.adventure_id)
""") == 0, "an action sits on another campaign's branch"
assert one("""
SELECT COUNT(*) FROM knowledge_sources k
LEFT JOIN adventures adv ON adv.id = k.adventure_id
WHERE adv.id IS NULL
""") == 0, "knowledge owned by no campaign"
assert one("""
SELECT COUNT(*) FROM state_events e
LEFT JOIN actions a ON a.id = e.action_id
WHERE e.action_id IS NOT NULL
AND (a.id IS NULL OR a.adventure_id <> e.adventure_id)
""") == 0, "a state event names a turn in another campaign"
assert one("""
SELECT COUNT(*) FROM adventures adv
LEFT JOIN actions a
ON a.branch_id = adv.head_branch_id AND a.depth = adv.head_depth
AND a.adventure_id = adv.id
WHERE adv.head_depth >= 0 AND a.id IS NULL
""") == 0, "the head points outside the retained story"
finally:
connection.close()
+747
View File
@@ -0,0 +1,747 @@
"""M9: what a broken bundle does, and what it must never do.
A campaign bundle is a file on a disk. It can be truncated by a full volume,
mangled by a text editor, hand-written by somebody curious, or produced by a
build that does not exist yet. Every case below starts from a real export of the
M9 fixture and breaks exactly one thing about it, so what each test measures is
that one break rather than a fixture nobody would recognise.
## The two rules
**Nothing lands.** A refused import leaves no campaign, no branch, no orphan
action, no Save Point pointing at nothing, and no knowledge owned by a campaign
that does not exist. `bundle.plan` has no side effects and runs before a row is
written, and the endpoint commits once, so a refusal is a refusal — checked here
by counting rows before and after rather than by trusting the status code.
**Nothing is fetched, read or run.** A bundle is data. A URL in it is text, a
filename in it is text, and a path in it is text. No test here needs a network
guard to pass, which is the point: there is no code path that would use one.
## Refuse or repair, and why each is which
The two are not interchangeable and the choice is made per field, on one
question — *does a wrong value here make the rest of the campaign wrong?*
refuse the head, the tree, the audit trail
a head past the story misplaces every read of it; a node on a
branch that is not listed is a story with a hole; an audit record
naming a turn that is not there leaves state nobody can explain
repair a knowledge classification that is unreadable, a filename with a
path in it, a live flag nobody set
the value is not load-bearing for anything but itself
drop a Save Point that names no turn, a summary with no coordinate
a bookmark costs a bookmark; refusing the campaign to save it
would lose the story
What none of them ever is: **retarget**. A Save Point whose position is not in
the file does not get moved to a nearby one, because the reader named a position
and no other position is the one they named.
python -m pytest tests/test_m9_corrupt_bundles.py -v
"""
import copy
import json
import pytest
from fastapi import Depends
from fastapi.testclient import TestClient
from app import auth, limits, memorybank, models
from app.database import Base, SessionLocal, engine, get_db
from app.knowledge import embeddings
from app.main import app
from app.routers import adventures
import m9_fixture
from fakes import ScriptedProvider
from test_m9_portability import StubDerived
@pytest.fixture()
def client(monkeypatch):
Base.metadata.create_all(bind=engine)
memorybank._vector_cache.clear()
embeddings._cache.clear()
setup = SessionLocal()
user = models.User(is_guest=False, email="corrupt@example.com")
setup.add(user)
setup.flush()
setup.add(models.Settings(
user_id=user.id, model="test-model", embedding_model="stub-embed",
context_token_budget=4000, max_output_tokens=400,
))
adventure = models.Adventure(
user_id=user.id, title="Source campaign",
campaign_canon=m9_fixture.CAMPAIGN_CANON,
)
setup.add(adventure)
setup.flush()
setup.add(models.Action(
adventure_id=adventure.id, type="start", text=m9_fixture.OPENING,
))
setup.commit()
adv_id, user_id = adventure.id, user.id
setup.close()
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
monkeypatch.setattr(memorybank, "embedding_provider", lambda s: StubDerived())
monkeypatch.setattr(memorybank, "summary_provider", lambda s: StubDerived())
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 = adv_id
try:
yield test_client
finally:
app.dependency_overrides.clear()
adventures.turns._active_turns.clear()
memorybank._vector_cache.clear()
embeddings._cache.clear()
Base.metadata.drop_all(bind=engine)
@pytest.fixture(scope="module")
def _cache():
"""One place to keep the exported fixture between tests in this module."""
return {}
@pytest.fixture()
def good(client):
"""A real, valid export of the M9 fixture, ready to be broken."""
m9_fixture.build(client, client.adv_id)
response = client.get(f"/api/adventures/{client.adv_id}/export")
assert response.status_code == 200
return response.json()
# ------------------------------------------------------------------ the rules
def _counts() -> dict:
"""Every row that an import can create, per table."""
with SessionLocal() as db:
return {
model.__name__: db.query(model).count()
for model in (
models.Adventure, models.Branch, models.Action, models.Memory,
models.Summary, models.Checkpoint, models.StateEvent,
models.StateProposal, models.KnowledgeSource,
models.KnowledgeChunk, models.StoryCard,
)
}
def refused(client, payload, *, status=(400, 409, 413, 422)) -> str:
"""Imports expecting a refusal, and asserts that nothing at all landed."""
before = _counts()
response = client.post("/api/adventures/import", json=payload)
assert response.status_code in status, (
f"expected a refusal, got {response.status_code}: {response.text[:400]}"
)
assert _counts() == before, (
"a refused import wrote rows: "
f"{ {k: (before[k], v) for k, v in _counts().items() if before[k] != v} }"
)
body = response.json()
return str(body.get("detail", body))
def accepted(client, payload) -> int:
response = client.post("/api/adventures/import", json=payload)
assert response.status_code == 201, response.text[:500]
return response.json()["id"]
def broken(good: dict, **changes) -> dict:
return dict(copy.deepcopy(good), **changes)
# -------------------------------------------------------- format and version
def test_a_payload_that_is_not_an_object_is_refused(client):
for payload in ([], "a string", 7):
response = client.post("/api/adventures/import", json=payload)
assert response.status_code in (400, 422), response.text[:200]
def test_an_empty_object_is_refused(client):
assert "format" in refused(client, {}).lower() or "export" in refused(client, {})
def test_a_missing_format_is_refused(client, good):
payload = copy.deepcopy(good)
del payload["format"]
refused(client, payload)
def test_a_format_of_the_wrong_type_is_refused(client, good):
for wrong in (3, None, ["ai-dnd-adventure-v3"], {"v": 3}):
refused(client, broken(good, format=wrong))
def test_an_unsupported_future_version_is_refused_with_its_name(client, good):
detail = refused(client, broken(good, format="ai-dnd-adventure-v42"))
assert "ai-dnd-adventure-v42" in detail
# ------------------------------------------------------------- the tree graph
def test_an_action_on_a_branch_the_file_does_not_list_is_refused(client, good):
payload = copy.deepcopy(good)
payload["actions"][0]["branch"] = 99
assert "99" in refused(client, payload)
def test_a_branch_forking_from_one_listed_after_it_is_refused(client, good):
"""Which is also how a cycle is made impossible rather than detected.
A branch may only fork from a branch listed before it, so the graph is
acyclic by construction. Without it a lineage walk on a hand-edited file
would not terminate.
"""
payload = copy.deepcopy(good)
payload["branches"][0] = {"parent": 1, "forkDepth": 0}
refused(client, payload)
def test_a_branch_that_forks_from_itself_is_refused(client, good):
payload = copy.deepcopy(good)
payload["branches"][1] = {"parent": 1, "forkDepth": 3}
refused(client, payload)
def test_a_fork_with_no_depth_is_refused(client, good):
payload = copy.deepcopy(good)
payload["branches"][1] = {"parent": 0}
assert "depth" in refused(client, payload)
def test_an_action_with_no_depth_is_refused(client, good):
payload = copy.deepcopy(good)
payload["actions"][1]["depth"] = None
assert "depth" in refused(client, payload)
def test_an_action_with_a_negative_depth_is_refused(client, good):
payload = copy.deepcopy(good)
payload["actions"][1]["depth"] = -4
refused(client, payload)
def test_a_head_past_the_story_is_refused(client, good):
assert "ends at" in refused(client, broken(good, headDepth=10_000))
def test_a_head_depth_of_the_wrong_type_is_refused(client, good):
for wrong in ("3", 3.5, True, [3]):
refused(client, broken(good, headDepth=wrong))
def test_a_head_branch_that_is_not_listed_falls_back_to_the_root(client, good):
"""Repaired rather than refused, and the repair is the safe direction.
The head *depth* is checked against the story and refused when it disagrees,
because a wrong depth silently moves the reader. A head *branch* that names
nothing cannot be read at all, so there is no wrong position to land at —
the root is where a campaign with no chosen branch is read.
"""
payload = copy.deepcopy(good)
payload["headBranch"] = 77
payload.pop("headDepth") # the depth belongs to the branch it names
copy_id = accepted(client, payload)
with SessionLocal() as db:
adventure = db.get(models.Adventure, copy_id)
root = (
db.query(models.Branch)
.filter(models.Branch.adventure_id == copy_id,
models.Branch.parent_branch_id.is_(None))
.first()
)
assert adventure.head_branch_id == root.id
def test_two_actions_claiming_one_identity_are_refused(client, good):
"""Take parentage and the whole audit trail hang off these ids."""
payload = copy.deepcopy(good)
payload["actions"][1]["id"] = payload["actions"][0]["id"]
assert "both call themselves" in refused(client, payload)
def test_a_turn_whose_takes_are_all_dead_still_tells_one(client, good):
"""Repaired, because a turn with no live attempt disappears from the story."""
payload = copy.deepcopy(good)
for action in payload["actions"]:
action["live"] = False
copy_id = accepted(client, payload)
with SessionLocal() as db:
rows = (
db.query(models.Action)
.filter(models.Action.adventure_id == copy_id)
.all()
)
per_turn = {}
for row in rows:
per_turn.setdefault((row.branch_id, row.depth), []).append(row)
for group in per_turn.values():
assert sum(1 for row in group if row.live) == 1
def test_a_parent_naming_a_node_the_file_does_not_hold_is_ignored(client, good):
"""Dropped, not refused: a wrong parent costs a pager, not a campaign."""
payload = copy.deepcopy(good)
for action in payload["actions"]:
if action.get("parentId") is not None:
action["parentId"] = 999_999
copy_id = accepted(client, payload)
story = client.get(f"/api/adventures/{copy_id}").json()
assert story["actions"], "the campaign did not import"
def test_a_node_that_is_its_own_parent_does_not_loop(client, good):
payload = copy.deepcopy(good)
for action in payload["actions"]:
if action.get("id") is not None:
action["parentId"] = action["id"]
copy_id = accepted(client, payload)
with SessionLocal() as db:
assert db.query(models.Action).filter(
models.Action.adventure_id == copy_id,
models.Action.parent_id == models.Action.id,
).count() == 0
# And the pager still resolves rather than recursing.
assert client.get(f"/api/adventures/{copy_id}").status_code == 200
# ----------------------------------------------------------------- save points
def test_a_save_point_beyond_the_retained_story_is_dropped_not_retargeted(
client, good
):
payload = copy.deepcopy(good)
original = payload["checkpoints"][0]["name"]
payload["checkpoints"][0]["depth"] = 5_000
copy_id = accepted(client, payload)
landed = client.get(f"/api/adventures/{copy_id}/checkpoints").json()
assert original not in {point["name"] for point in landed}
assert all(point["depth"] < 5_000 for point in landed)
assert landed, "the good Save Point was lost with the bad one"
def test_a_save_point_on_a_branch_that_is_not_listed_is_dropped(client, good):
payload = copy.deepcopy(good)
payload["checkpoints"][0]["branch"] = 44
copy_id = accepted(client, payload)
landed = client.get(f"/api/adventures/{copy_id}/checkpoints").json()
assert len(landed) == len(good["checkpoints"]) - 1
def test_a_save_point_with_a_blank_name_is_dropped(client, good):
payload = copy.deepcopy(good)
payload["checkpoints"][0]["name"] = " "
copy_id = accepted(client, payload)
assert len(client.get(f"/api/adventures/{copy_id}/checkpoints").json()) == \
len(good["checkpoints"]) - 1
def test_a_checkpoints_section_that_is_not_a_list_costs_the_bookmarks_only(
client, good
):
copy_id = accepted(client, broken(good, checkpoints={"nope": 1}))
assert client.get(f"/api/adventures/{copy_id}/checkpoints").json() == []
assert client.get(f"/api/adventures/{copy_id}").json()["actions"]
# ------------------------------------------------------------ state and audit
def test_a_state_section_that_is_not_a_list_is_refused(client, good):
assert "list" in refused(client, broken(good, stateEvents={"a": 1}))
assert "list" in refused(client, broken(good, stateProposals="events"))
def test_a_state_event_that_is_not_an_object_is_refused(client, good):
payload = copy.deepcopy(good)
payload["stateEvents"][0] = "an event"
refused(client, payload)
def test_a_state_event_with_no_type_is_refused(client, good):
payload = copy.deepcopy(good)
payload["stateEvents"][0]["eventType"] = ""
assert "type" in refused(client, payload)
def test_an_event_naming_a_turn_the_file_does_not_hold_is_refused(client, good):
payload = copy.deepcopy(good)
payload["stateEvents"][0]["action"] = 424_242
assert "424242" in refused(client, payload).replace(",", "")
def test_a_proposal_naming_a_turn_the_file_does_not_hold_is_refused(client, good):
payload = copy.deepcopy(good)
payload["stateProposals"][0]["action"] = 424_242
refused(client, payload)
def test_an_event_naming_a_proposal_that_is_gone_keeps_its_coordinate(client, good):
"""`ON DELETE SET NULL`, as a file. The event is the accepted change.
A proposal can be deleted while the event it produced stands — the schema
says so — so an event whose proposal is not in the file is not a broken
file. It loses the pointer and keeps everything that makes it an audit
record: what changed, where, and who asserted it.
"""
payload = copy.deepcopy(good)
payload["stateProposals"] = []
copy_id = accepted(client, payload)
events = client.get(
f"/api/adventures/{copy_id}/state/events?limit=500"
).json()
assert len(events) == len(good["stateEvents"])
assert any(e["source"] == "manual_correction" for e in events)
with SessionLocal() as db:
assert db.query(models.StateEvent).filter(
models.StateEvent.adventure_id == copy_id,
models.StateEvent.proposal_id.isnot(None),
).count() == 0
def test_a_malformed_narrative_state_costs_the_state_and_not_the_campaign(
client, good
):
"""M5's rule, unchanged: a malformed document is normalised, not fatal.
The story is the valuable thing. A state section that arrives as nonsense
becomes an empty document — which is honest, because nothing in it can be
trusted — and every turn still imports.
"""
copy_id = accepted(client, broken(good, narrativeState={"entities": "wrong"}))
story = client.get(f"/api/adventures/{copy_id}").json()
assert len(story["actions"]) == len(
client.get(f"/api/adventures/{client.adv_id}").json()["actions"]
)
state = client.get(f"/api/adventures/{copy_id}/state").json()
assert state["document"]["entities"] == {}
def test_a_per_position_snapshot_that_is_not_an_object_is_dropped(client, good):
payload = copy.deepcopy(good)
for action in payload["actions"]:
if "narrativeStateAfter" in action:
action["narrativeStateAfter"] = "not a document"
copy_id = accepted(client, payload)
assert client.get(f"/api/adventures/{copy_id}").status_code == 200
# Arriving at such a position gives the empty document rather than a
# later position's state, which is M5's finding 3.
client.post(f"/api/adventures/{copy_id}/undo")
assert client.get(f"/api/adventures/{copy_id}/state").json()["document"]["facts"] == []
# -------------------------------------------------------------- knowledge
def test_a_knowledge_section_that_is_not_a_list_is_refused(client, good):
assert "list" in refused(client, broken(good, knowledge={"a": 1}))
def test_a_source_with_no_content_is_refused(client, good):
"""Refused rather than dropped, and M7 chose that deliberately.
A campaign whose imported Canon quietly did not arrive is a campaign whose
narrator has stopped being told the rules, and the reader has no way to
notice.
"""
payload = copy.deepcopy(good)
payload["knowledge"][0]["content"] = ""
assert "content" in refused(client, payload)
def test_a_source_with_an_unknown_classification_is_refused(client, good):
payload = copy.deepcopy(good)
payload["knowledge"][0]["classification"] = "gospel"
assert "classification" in refused(client, payload)
def test_a_source_that_is_not_an_object_is_refused(client, good):
payload = copy.deepcopy(good)
payload["knowledge"][0] = "canon.md"
refused(client, payload)
def test_an_unreadable_visibility_becomes_normal_rather_than_hidden(client, good):
"""Repaired, and in the direction that reveals rather than conceals.
Visibility is not a permission system — the person who imported the file can
always read it — so a source that should have been narrator-only and lands
as normal costs a spoiler in the prompt framing. The other direction would
silently withhold material the reader expects the narrator to use, with
nothing saying so.
"""
payload = copy.deepcopy(good)
for source in payload["knowledge"]:
source["visibility"] = "invisible"
copy_id = accepted(client, payload)
library = client.get(f"/api/adventures/{copy_id}/knowledge").json()
assert all(source["visibility"] == "normal" for source in library)
def test_a_content_hash_that_disagrees_is_recomputed_and_reported(client, good):
"""The one derived value in the file, and the only reason it is there.
The stored hash is recomputed from what actually arrived, so it always
describes the content. The file's own claim is not silently discarded
either: a mismatch means the file was edited after it was written, and the
reader is told on the source itself.
"""
payload = copy.deepcopy(good)
payload["knowledge"][0]["contentHash"] = "0" * 64
copy_id = accepted(client, payload)
library = client.get(f"/api/adventures/{copy_id}/knowledge").json()
edited = [s for s in library if s["content_hash"] != "0" * 64]
assert len(edited) == len(library)
detail = client.get(
f"/api/adventures/{copy_id}/knowledge/{library[0]['id']}"
).json()
assert "did not match" in detail["notes"]
def test_more_sources_than_the_cap_is_refused(client, good, monkeypatch):
from app.knowledge import importer
monkeypatch.setattr(importer, "MAX_SOURCES_PER_ADVENTURE", 2)
assert "limit" in refused(client, good)
def test_an_oversized_source_is_refused(client, good, monkeypatch):
from app.knowledge import importer
monkeypatch.setattr(importer, "MAX_SOURCE_BYTES", 32)
assert "larger than" in refused(client, good)
# ------------------------------------------------------------- provenance
def test_a_context_snapshot_that_is_not_an_object_is_dropped(client, good):
"""Evidence is restored verbatim or not at all. It is never guessed at."""
payload = copy.deepcopy(good)
payload["actions"] = [
{k: v for k, v in action.items() if k != "contextSnapshotZ"}
| ({"contextSnapshot": "the prompt was long"}
if m9_fixture.snapshot_in(action) else {})
for action in payload["actions"]
]
copy_id = accepted(client, payload)
page = client.get(f"/api/adventures/{copy_id}").json()
narrator = [a for a in page["actions"] if a["type"] == "ai"]
assert narrator
for action in narrator:
response = client.get(
f"/api/adventures/{copy_id}/actions/{action['id']}/context"
)
assert response.status_code == 404, "a mangled snapshot was restored"
def test_a_snapshot_whose_knowledge_block_is_nonsense_does_not_break_the_import(
client, good
):
payload = copy.deepcopy(good)
rewritten = []
for action in payload["actions"]:
snapshot = m9_fixture.snapshot_in(action)
if isinstance(snapshot, dict) and "knowledge" in snapshot:
snapshot["knowledge"] = ["not", "a", "report"]
rewritten.append(m9_fixture.with_snapshot(action, snapshot))
else:
rewritten.append(action)
payload["actions"] = rewritten
copy_id = accepted(client, payload)
assert client.get(f"/api/adventures/{copy_id}").status_code == 200
def test_a_snapshot_naming_an_impossible_source_is_relinked_to_nothing(
client, good
):
payload = copy.deepcopy(good)
rewritten = []
for action in payload["actions"]:
snapshot = m9_fixture.snapshot_in(action)
if not isinstance(snapshot, dict):
rewritten.append(action)
continue
for record in (snapshot.get("knowledge") or {}).get("used") or []:
record["source_id"] = -1
rewritten.append(m9_fixture.with_snapshot(action, snapshot))
payload["actions"] = rewritten
copy_id = accepted(client, payload)
with SessionLocal() as db:
from sqlalchemy.orm import undefer
for row in (
db.query(models.Action)
.filter(models.Action.adventure_id == copy_id)
.options(undefer(models.Action.context_snapshot))
):
snapshot = row.context_snapshot
if not isinstance(snapshot, dict):
continue
for record in (snapshot.get("knowledge") or {}).get("used") or []:
assert record["source_id"] is None
# ------------------------------------------------------------ summaries
def test_a_summary_with_no_coordinate_is_dropped_not_placed(client, good):
"""Placing it at a guess is how E03's leak would arrive by a new route."""
payload = copy.deepcopy(good)
payload["summaries"][0]["depth"] = None
copy_id = accepted(client, payload)
with SessionLocal() as db:
landed = db.query(models.Summary).filter(
models.Summary.adventure_id == copy_id
).count()
assert landed == len(good["summaries"]) - 1
def test_a_summaries_section_that_is_not_a_list_costs_the_summaries_only(
client, good
):
copy_id = accepted(client, broken(good, summaries="a paragraph"))
assert client.get(f"/api/adventures/{copy_id}").json()["actions"]
with SessionLocal() as db:
assert db.query(models.Summary).filter(
models.Summary.adventure_id == copy_id
).count() == 0
# ------------------------------------------------------------ caps and size
def test_more_actions_than_the_cap_is_refused(client, good, monkeypatch):
monkeypatch.setattr(limits, "MAX_ACTIONS_PER_ADVENTURE", 3)
monkeypatch.setitem(limits._BUNDLE_LIST_CAPS, "actions", 3)
assert "limit" in refused(client, good)
def test_more_branches_than_the_cap_is_refused(client, good, monkeypatch):
monkeypatch.setattr(limits, "MAX_BRANCHES_PER_ADVENTURE", 1)
monkeypatch.setitem(limits._BUNDLE_LIST_CAPS, "branches", 1)
assert "limit" in refused(client, good)
def test_a_body_past_the_import_ceiling_is_refused_before_it_is_parsed(client):
"""413 from the middleware, on the declared length, before any read."""
padding = "x" * (limits.MAX_IMPORT_BODY_BYTES + 1024)
response = client.post(
"/api/adventures/import",
content=json.dumps({"format": "ai-dnd-adventure-v3", "title": padding}),
headers={"Content-Type": "application/json"},
)
assert response.status_code == 413
assert "too large" in response.json()["detail"].lower()
# ------------------------------------------------- the transaction, not the plan
def test_a_failure_deep_inside_the_write_leaves_nothing_behind(
client, good, monkeypatch
):
"""The other half of atomicity, and the half the planner cannot provide.
Every test above is refused by `bundle.plan`, which has no side effects — so
they prove the *planner*, and a passing planner would look identical if the
write phase left debris. This one breaks something the planner has already
approved, half way through writing: the branches, the nodes, their
parentage, the memories, the head and the Save Points are all in the session
by then.
What must survive that is the whole transaction rolling back — every table,
not merely the adventure row. A half-written campaign is the outcome L01
forbids for a turn, and an import is the other place it could happen.
"""
from app import bundle as bundle_module
def explode(*args, **kwargs):
raise RuntimeError("simulated failure deep inside the write")
monkeypatch.setattr(bundle_module, "_write_summaries", explode)
before = _counts()
with pytest.raises(RuntimeError, match="simulated failure"):
client.post("/api/adventures/import", json=good)
assert _counts() == before, (
"a failed write left rows behind: "
f"{ {k: (before[k], v) for k, v in _counts().items() if before[k] != v} }"
)
def test_the_session_is_usable_after_a_failed_import(client, good, monkeypatch):
"""The rollback is explicit, so the next request is not poisoned by it.
Left to the session closing, a failure would leave the request's session in
a state the next caller inherits only by luck of pooling. `bundle_io` rolls
back and re-raises, so the very next import succeeds.
"""
from app import bundle as bundle_module
calls = {"n": 0}
original = bundle_module._write_summaries
def once(*args, **kwargs):
calls["n"] += 1
if calls["n"] == 1:
raise RuntimeError("simulated, once")
return original(*args, **kwargs)
monkeypatch.setattr(bundle_module, "_write_summaries", once)
with pytest.raises(RuntimeError):
client.post("/api/adventures/import", json=good)
copy_id = accepted(client, good)
assert client.get(f"/api/adventures/{copy_id}").json()["actions"]
# --------------------------------------------------------------- inert data
def test_a_url_in_a_bundle_stays_text(client, good):
"""H01/G08 for the import path: nothing in a file is ever fetched.
There is no allowlist to test and no request to intercept, which is the
result rather than a gap — the import has no code that could make one. What
is asserted is that the text arrives as text.
"""
payload = copy.deepcopy(good)
payload["knowledge"][0]["content"] = (
"# Sources\n\nSee https://example.invalid/secret.txt and "
"file:///etc/passwd and ![map](https://example.invalid/map.png)\n"
)
copy_id = accepted(client, payload)
library = client.get(f"/api/adventures/{copy_id}/knowledge").json()
detail = client.get(
f"/api/adventures/{copy_id}/knowledge/{library[0]['id']}"
).json()
assert "https://example.invalid/secret.txt" in detail["content"]
def test_a_path_in_a_bundle_never_becomes_a_path(client, good):
"""H08. `originalFilename` is metadata; the import stores no file."""
payload = copy.deepcopy(good)
for hostile in ("../../../etc/passwd", "/etc/shadow", "C:\\Windows\\hosts",
"....//....//etc/passwd"):
payload["knowledge"][0]["originalFilename"] = hostile
copy_id = accepted(client, payload)
library = client.get(f"/api/adventures/{copy_id}/knowledge").json()
for source in library:
assert "/" not in source["original_filename"]
assert "\\" not in source["original_filename"]
assert ".." not in source["original_filename"]
def test_a_title_that_looks_like_a_command_is_stored_as_a_title(client, good):
payload = broken(good, title="; rm -rf / #")
copy_id = accepted(client, payload)
assert client.get(f"/api/adventures/{copy_id}").json()["title"] == "; rm -rf / #"
def test_an_over_long_title_is_truncated_rather_than_refused(client, good):
copy_id = accepted(client, broken(good, title="A" * 5_000))
title = client.get(f"/api/adventures/{copy_id}").json()["title"]
assert 0 < len(title) <= 200
+365
View File
@@ -0,0 +1,365 @@
"""M9: every older bundle still imports, and none is reinterpreted.
A backup that stops importing is not a backup, so the importer keeps every
version it has ever written. That is the easy half. The hard half is the rule
`V1-ACCEPTANCE-TESTS.md` I07 states about the head and this file generalises:
> Do not reinterpret missing legacy data using modern assumptions that did not
> exist when the file was written.
An older file is missing things because its **format** could not carry them, not
because the campaign lacked them, and the two demand opposite treatment. A file
written before the head was carried opens at its tip, because tip was the only
position that format could represent — reproducing what it recorded. A file
written before state events existed opens with no state events, because
manufacturing an audit trail from the snapshots it does carry would be this
build's reading of a history it never saw, handed to a reader as the record of
what happened.
Each seam below is built by taking a real v3 export and removing exactly what
the older format could not hold. That is deliberate: a checked-in fixture file
drifts, and a hand-written one tests a shape nothing ever wrote.
python -m pytest tests/test_m9_legacy_bundles.py -v
"""
import copy
import pytest
from fastapi import Depends
from fastapi.testclient import TestClient
from app import auth, bundle, limits, memorybank, models
from app.database import Base, SessionLocal, engine, get_db
from app.knowledge import embeddings
from app.main import app
from app.routers import adventures
import m9_fixture
from fakes import ScriptedProvider
from test_m9_portability import StubDerived
@pytest.fixture()
def client(monkeypatch):
Base.metadata.create_all(bind=engine)
memorybank._vector_cache.clear()
embeddings._cache.clear()
setup = SessionLocal()
user = models.User(is_guest=False, email="legacy@example.com")
setup.add(user)
setup.flush()
setup.add(models.Settings(
user_id=user.id, model="test-model", embedding_model="stub-embed",
context_token_budget=4000, max_output_tokens=400,
))
adventure = models.Adventure(
user_id=user.id, title="Source",
campaign_canon=m9_fixture.CAMPAIGN_CANON,
)
setup.add(adventure)
setup.flush()
setup.add(models.Action(
adventure_id=adventure.id, type="start", text=m9_fixture.OPENING,
))
setup.commit()
adv_id, user_id = adventure.id, user.id
setup.close()
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider)
monkeypatch.setattr(memorybank, "embedding_provider", lambda s: StubDerived())
monkeypatch.setattr(memorybank, "summary_provider", lambda s: StubDerived())
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 = adv_id
try:
yield test_client
finally:
app.dependency_overrides.clear()
adventures.turns._active_turns.clear()
memorybank._vector_cache.clear()
embeddings._cache.clear()
Base.metadata.drop_all(bind=engine)
@pytest.fixture()
def current(client):
"""A real v3 export of the M9 fixture, to age backwards from."""
m9_fixture.build(client, client.adv_id)
response = client.get(f"/api/adventures/{client.adv_id}/export")
assert response.status_code == 200
return response.json()
# ---------------------------------------------------- ageing a bundle backwards
def as_of(payload: dict, era: str) -> dict:
"""The same campaign as an export from an earlier era.
Each step removes only what that era's format genuinely could not carry, so
the result is the file a build of that vintage would have produced from this
campaign — not a mutilated modern one.
"""
older = copy.deepcopy(payload)
eras = ("pre-m9", "pre-m7", "pre-m5", "pre-save-points", "pre-active-head")
assert era in eras, era
reached = eras.index(era)
# M9 (v3): the evidence sections and the node identities.
older["format"] = bundle.TREE_FORMAT
for key in ("stateEvents", "stateProposals", "summaries"):
older.pop(key, None)
for action in older["actions"]:
for key in ("contextSnapshot", "contextSnapshotZ", "id", "parentId"):
action.pop(key, None)
for memory in older.get("memories") or []:
memory.pop("authority", None)
for source in older.get("knowledge") or []:
for key in ("sourceId", "parserVersion", "chunkingVersion"):
source.pop(key, None)
if reached == 0:
return older
# M7: the imported knowledge library.
older.pop("knowledge", None)
if reached == 1:
return older
# M5: the authoritative narrative state, its per-position snapshots, and
# the campaign's own canon.
for key in ("narrativeState", "campaignCanon"):
older.pop(key, None)
for action in older["actions"]:
for key in ("narrativeStateAfter", "stateChanges"):
action.pop(key, None)
if reached == 2:
return older
# M4: named Save Points.
older.pop("checkpoints", None)
if reached == 3:
return older
# M3: the chosen head. Such a file could only ever be read at its tip.
older.pop("headDepth", None)
return older
def bring_back(client, payload) -> int:
response = client.post("/api/adventures/import", json=payload)
assert response.status_code == 201, response.text[:500]
return response.json()["id"]
def _rows(adv_id, model) -> int:
with SessionLocal() as db:
return db.query(model).filter(model.adventure_id == adv_id).count()
def _tree_size(client, adv_id) -> int:
"""Every retained row, which is what "no accepted story was lost" means."""
return len(client.get(f"/api/adventures/{adv_id}/export").json()["actions"])
# --------------------------------------------------------------- every era
@pytest.mark.parametrize("era", [
"pre-m9", "pre-m7", "pre-m5", "pre-save-points", "pre-active-head",
])
def test_no_accepted_story_is_lost_at_any_seam(client, current, era):
"""The floor under every case below: the turns all arrive.
Counted over the whole retained tree rather than the active path, because
the head moves between eras and a count of what is on screen would move
with it.
"""
copy_id = bring_back(client, as_of(current, era))
assert _tree_size(client, copy_id) == len(current["actions"])
@pytest.mark.parametrize("era", [
"pre-m9", "pre-m7", "pre-m5", "pre-save-points", "pre-active-head",
])
def test_nothing_is_invented_to_fill_a_gap_the_format_left(client, current, era):
"""Absent means the format could not say. It never means "make one up".
Each era is checked against what that era's files could hold: a pre-M9 file
gets no audit trail and no summaries, a pre-M7 file no knowledge, a pre-M5
file no state, a pre-Save-Point file no Save Points.
"""
copy_id = bring_back(client, as_of(current, era))
reached = ("pre-m9", "pre-m7", "pre-m5", "pre-save-points",
"pre-active-head").index(era)
assert _rows(copy_id, models.StateEvent) == 0
assert _rows(copy_id, models.StateProposal) == 0
assert _rows(copy_id, models.Summary) == 0
if reached >= 1:
assert _rows(copy_id, models.KnowledgeSource) == 0
assert _rows(copy_id, models.KnowledgeChunk) == 0
if reached >= 2:
state = client.get(f"/api/adventures/{copy_id}/state").json()
assert state["document"]["facts"] == []
assert state["document"]["entities"] == {}
assert client.get(f"/api/adventures/{copy_id}").json()["canon_rules"] == []
if reached >= 3:
assert _rows(copy_id, models.Checkpoint) == 0
# ------------------------------------------------------ the head, era by era
def test_a_pre_m9_file_still_opens_at_the_head_it_recorded(client, current):
"""v2 carried the head, so it is honoured exactly as before."""
copy_id = bring_back(client, as_of(current, "pre-m9"))
with SessionLocal() as db:
assert db.get(models.Adventure, copy_id).head_depth == current["headDepth"]
def test_a_pre_active_head_file_opens_at_its_tip(client, current):
"""I07's compatibility clause. Not a degraded path.
Such a file was written when the head could not be anywhere but the tip, so
opening it there reproduces the position it recorded. An import that refused
it, or that guessed some other position, would be the failure.
"""
copy_id = bring_back(client, as_of(current, "pre-active-head"))
with SessionLocal() as db:
adventure = db.get(models.Adventure, copy_id)
tip = max(
row.depth for row in
db.query(models.Action).filter(
models.Action.adventure_id == copy_id,
models.Action.branch_id == adventure.head_branch_id,
)
)
assert adventure.head_depth == tip
assert adventure.head_depth > current["headDepth"], (
"the fixture's head must really be behind its tip, or this proves nothing"
)
def test_a_pre_active_head_file_offers_no_redo_because_it_is_at_the_tip(
client, current
):
copy_id = bring_back(client, as_of(current, "pre-active-head"))
page = client.get(f"/api/adventures/{copy_id}").json()
assert page["can_redo"] is False
assert page["can_undo"] is True
# ----------------------------------------------------- what each era can do
def test_a_pre_m5_campaign_can_be_played_on_and_gains_state_from_there(
client, current
):
"""The M5 rule, applied to an import: no backfill, and no obstacle either.
An old campaign starts with an empty state because its narration was never
read by a state extractor. The next turn fills it in, which is what makes
"no backfill" a decision rather than a loss.
"""
copy_id = bring_back(client, as_of(current, "pre-m5"))
assert client.get(f"/api/adventures/{copy_id}/state").json()["empty"] is True
ScriptedProvider.replies = [
"The door gives at last.\n" + __import__("fakes").state_block([
{"type": "add_fact", "predicate": "tally", "value": 500,
"fact_id": "tally-500"}
])
]
played = client.post(f"/api/adventures/{copy_id}/actions",
json={"type": "do", "text": "push harder"})
assert played.status_code == 200, played.text[:300]
after = client.get(f"/api/adventures/{copy_id}/state").json()
assert after["empty"] is False
assert any(f["predicate"] == "tally" for f in after["document"]["facts"])
def test_a_pre_m7_campaign_needs_no_source_and_can_import_one(client, current):
copy_id = bring_back(client, as_of(current, "pre-m7"))
assert client.get(f"/api/adventures/{copy_id}/knowledge").json() == []
# It plays without one.
assert client.get(f"/api/adventures/{copy_id}/context").status_code == 200
# And gains one.
landed = m9_fixture.upload(
client, copy_id, "canon.md", m9_fixture.CANON_MD, "canon",
)
library = client.get(f"/api/adventures/{copy_id}/knowledge").json()
assert [s["id"] for s in library] == [landed]
assert library[0]["index_state"] == "ready"
def test_a_pre_save_point_campaign_can_be_given_one(client, current):
copy_id = bring_back(client, as_of(current, "pre-save-points"))
assert client.get(f"/api/adventures/{copy_id}/checkpoints").json() == []
made = client.post(f"/api/adventures/{copy_id}/checkpoints",
json={"name": "From here", "note": ""})
assert made.status_code == 201, made.text[:300]
assert made.json()["resolved"] is True
def test_a_pre_m9_campaign_re_exports_as_v3_without_gaining_evidence(
client, current
):
"""Re-exporting an old campaign does not turn absence into presence.
The file it writes is a v3 file, because that is what this build writes. Its
evidence sections are empty, because the campaign genuinely has none — and a
later reader can therefore trust a v3 file's empty `stateEvents` to mean
"this campaign has no audit trail" rather than "the file could not say".
"""
copy_id = bring_back(client, as_of(current, "pre-m9"))
again = client.get(f"/api/adventures/{copy_id}/export").json()
assert again["format"] == bundle.FORMAT
assert again["stateEvents"] == []
assert again["stateProposals"] == []
assert again["summaries"] == []
assert not any(a.get("contextSnapshotZ") for a in again["actions"])
# And the story it does have survives a second round trip unchanged.
twice = bring_back(client, again)
assert _tree_size(client, twice) == _tree_size(client, copy_id)
def test_a_v1_file_still_imports_and_reads_in_order(client):
"""The flat format, with its retries as a repeating group."""
copy_id = bring_back(client, {
"format": bundle.LEGACY_FORMAT,
"title": "An old flat file",
"memory": "Kept from before the tree.",
"actions": [
{"index": 0, "type": "start", "text": "It begins."},
{"index": 1, "type": "do", "text": "look around"},
{"index": 2, "type": "ai", "text": "Take two.",
"variants": [{"text": "Take one."}, {"text": "Take two."}],
"variantIndex": 1},
],
})
page = client.get(f"/api/adventures/{copy_id}").json()
assert [a["text"] for a in page["actions"]] == [
"It begins.", "look around", "Take two.",
]
assert page["memory"] == "Kept from before the tree."
# Both attempts arrived; only one is the story.
with SessionLocal() as db:
rows = db.query(models.Action).filter(
models.Action.adventure_id == copy_id, models.Action.type == "ai",
).all()
assert sorted(r.text for r in rows) == ["Take one.", "Take two."]
assert sum(1 for r in rows if r.live) == 1
def test_a_pre_m2_file_with_scripting_still_imports(client, current):
"""M2 removed campaign scripting. Its keys are ignored, not rejected.
The story, the tree and everything else in such a file are still worth
importing, and refusing the campaign over a subsystem that no longer exists
would lose all of it to reject one key.
"""
payload = as_of(current, "pre-m5")
payload["scripts"] = [{"name": "onTurn", "code": "state.gold += 10"}]
payload["scriptState"] = {"gold": 70}
copy_id = bring_back(client, payload)
assert _tree_size(client, copy_id) == len(current["actions"])
File diff suppressed because it is too large Load Diff
+11 -7
View File
@@ -350,13 +350,17 @@ def test_export_and_import_round_trips_variants(client):
assert [a["type"] for a in actions] == ["start", "do", "ai"]
assert actions[-1]["text"] == "Two."
# The pager reads 1/1 on the copy, because the import writes no
# `parent_id` and `annotate_takes` groups on it. The attempts are both
# there, at one coordinate, and `GET .../variants` still lists them. This
# is a gap in the import rather than in the drop: `take_count` has been the
# only number the client reads since SP9, and the import has never set the
# column it is derived from.
assert actions[-1]["take_count"] == 1
# The pager reads 2/2 on the copy, as it does on the original.
#
# It read 1/1 until M9, and this test recorded that as a gap in the import
# rather than in the export: the attempts were both there at one coordinate
# and `GET .../variants` listed them, but the import wrote no `parent_id`,
# so `annotate_takes` grouped on the coordinate instead. That is right for a
# plain retry and wrong the moment two takes of one turn each have takes of
# their own beneath them, which is why M9 carried the parentage rather than
# leaving the pager to a fallback. See `bundle._link_take_parents`.
assert actions[-1]["take_count"] == 2
assert actions[-1]["take_index"] == 1
variants = client.get(
f"/api/adventures/{imported}/actions/{actions[-1]['id']}/variants").json()
assert [v["text"] for v in variants] == ["One.", "Two."]
+18 -40
View File
@@ -1471,46 +1471,24 @@ def test_the_save_point_count_matches_what_blocks_the_deletion(client):
f"/api/adventures/{client.adv_id}/branches/{middle_branch}"
).status_code == 409
def test_both_branch_delete_surfaces_explain_the_save_point_rule():
"""The rule must be visible in every view the deletion is reachable from.
A source-level assertion, because the project has no frontend test runner
(M8). `test_offline_assets.py` reads the frontend the same way, for the same
reason: the check is worth having now, and it is honest about what it is —
it proves the wiring is in the build, not that a user saw it. The browser
smoke test performed at closeout is what proves the rendering.
Two files, because the branch list and the tree overlay each render their
own delete control, and a rule that held in one of them would not be a rule.
"""
from pathlib import Path
repo = Path(__file__).resolve().parents[2]
views = {
"the branch panel":
repo / "frontend/src/pages/Play/panels/BranchPanel.jsx",
"the tree overlay":
repo / "frontend/src/BranchMap.jsx",
}
for where, path in views.items():
source = path.read_text(encoding="utf-8")
assert "savePointsUnder" in source, (
f"{where} does not count the Save Points that protect a branch"
)
# The Delete control is disabled while Save Points protect the subtree,
# and says why rather than failing silently on the server.
assert "protecting > 0" in source, (
f"{where} does not disable Delete while Save Points protect the branch"
)
assert "deleting a Save Point deletes no story" in source, (
f"{where} does not tell the user how to proceed"
)
# The user-facing copy must not explain itself in schema terms.
for jargon in ("cascade", "foreign key", "foreign-key", "ON DELETE"):
assert jargon.lower() not in source.lower(), (
f"{where} uses implementation jargon in user-facing copy: {jargon}"
)
# The browser copy for deleting and restoring a Save Point was asserted here,
# by reading `SavePointPanel.jsx` as text. That check is gone, and this note is
# what replaced it.
#
# It existed because the project had no frontend test runner and the wording is
# load-bearing: a reader who believes Restore destroys their later story will
# not press it. M8 supplied the runner, and
# `frontend/src/pages/Play/panels/panels.test.jsx` now renders both
# confirmations and reads what they actually say — which is the thing this was
# approximating, done properly.
#
# It was also becoming unsound. JSX wraps prose across lines, so a substring
# match on a sentence broke on reflow rather than on a change of meaning, and
# the same check forbade the words "branch" and "fork" in a file whose own
# comments explain why those words are avoided.
#
# The server-side rule it protected — deleting a branch a Save Point is kept on
# is refused — is unchanged and tested above.
def test_creating_a_save_point_takes_the_campaigns_turn_lock(client):
+12 -2
View File
@@ -50,10 +50,20 @@ def payload() -> dict:
def test_the_shipped_file_is_a_bundle_this_build_can_import():
"""The file is written by an export, so a format change can strand it."""
"""The file is written by an export, so a format change can strand it.
It is checked against every version the importer reads rather than against
the newest one it writes, which is the property that actually matters and
the one the shipped file has to keep. M9 bumped the format to v3 and did not
regenerate this asset: the starter is a linear story with no state events,
no summaries and no stored prompts, so a v3 rewrite of it would differ from
the v2 file in the version string alone — and rewriting a shipped asset to
keep a test's equality holding would be changing the evidence to fit the
test. What it does need is to go on importing, which is asserted below.
"""
data = payload()
version = bundle.check_format(data)
assert version == bundle.FORMAT
assert version in bundle.READABLE
story = bundle.plan(data, version)
assert story["nodes"]
+9 -2
View File
@@ -21,7 +21,7 @@ import pytest
from fastapi import Depends
from fastapi.testclient import TestClient
from app import auth, limits, models
from app import auth, bundle as bundle_module, limits, models
from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.routers import adventures
@@ -425,6 +425,13 @@ def test_export_carries_the_whole_story(client):
array in `test_export_keeps_retry_attempts`. That change reflects the
same fact: a bundle that stores coordinates has no use for a repeating
group. Everything else here still passes unmodified.
M9 changed the same one line again, for the same kind of reason — the
version now says that the file can carry state events and historical
prompts as well as a tree. It is asserted against `bundle.FORMAT` this
time, so the next writer of a new version does not have to find this line:
what the test is about is that an export declares its version, not which
version this build happens to write.
"""
ScriptedProvider.replies = [gold_reply(t) for t in ["One.", "Two."]]
_play(client, "go north")
@@ -433,7 +440,7 @@ def test_export_carries_the_whole_story(client):
r = client.get(f"/api/adventures/{client.adv_id}/export")
assert r.status_code == 200, r.text
bundle = r.json()
assert bundle["format"] == "ai-dnd-adventure-v2"
assert bundle["format"] == bundle_module.FORMAT
assert bundle["title"] == "Cave"
assert [a["text"] for a in bundle["actions"]] == [
OPENING, "> You go north.", "One.", "> You go south.", "Two.",
+80
View File
@@ -474,3 +474,83 @@ def test_naming_a_take_that_is_already_the_story_just_plays_on(client):
_play(client, "carry on", after_id=live.id)
assert _branch_count(client.adv_id) == before
def test_first_person_input_is_not_prefixed_with_you():
"""M8. `BROWSER-UX-SPEC.md` §11-12: one field, and the reader writes "I …".
The AI Dungeon convention prefixes a player action with `> You `, which was
right when the Do mode asked for a bare verb phrase. With one
natural-language field it produced `> You I enter the tavern.` — in the
transcript, in the replayed history, and so in the narration, where a small
model imitated it and wrote "You I thank her". Found in the first browser
pass of the M8 composer.
Tested against the shared normalizer rather than a rendered component,
because every surface — storage, transcript, replayed history, export —
reads the result of this one function.
The `>` marker is what distinguishes a player turn in the prompt, so it is
unchanged in every case; only the redundant subject is dropped.
"""
from app.routers.adventures.turns import format_player_input as fmt
# --- The §11 examples, verbatim from the specification ---
assert fmt("do", "I enter the tavern.") == "> I enter the tavern."
assert fmt("do", "I ask Mara about Edrin.") == "> I ask Mara about Edrin."
assert fmt("do", "I wait quietly and watch the room.") == (
"> I wait quietly and watch the room.")
# --- No duplicate subject, in any first-person phrasing ---
for text in ("I walk into the tavern", "I'm going to knock", "I've seen this before",
"I'll wait", "I'd rather not", "My hand finds the key",
"We head north", "We're leaving"):
out = fmt("do", text)
assert "You I" not in out, f"duplicate subject in {out!r}"
assert "You My" not in out, f"duplicate subject in {out!r}"
assert "You We" not in out, f"duplicate subject in {out!r}"
assert out.startswith("> "), f"lost the player-turn marker in {out!r}"
# --- The legacy bare action still normalizes, which is deliberate ---
assert fmt("do", "open the door") == "> You open the door."
assert fmt("do", "look around") == "> You look around."
# And an explicit "You ..." is de-duplicated rather than doubled.
assert fmt("do", "You leave the tavern") == "> You leave the tavern."
# --- Dialogue and out-of-character direction are untouched ---
assert fmt("say", "Have you seen Edrin?") == '> You say "Have you seen Edrin?"'
assert fmt("say", "I think he went north") == '> You say "I think he went north."'
assert fmt("story", "Keep this scene tense, but do not start a fight yet.") == (
"Keep this scene tense, but do not start a fight yet.")
# --- The marker survives, and is never doubled ---
for kind in ("do", "say"):
assert fmt(kind, "I move").startswith("> ")
assert "> > " not in fmt("do", "I move")
def test_normalized_player_text_reaches_history_exactly_once(client):
"""The normalization must survive into the replayed context, unduplicated.
A `format_player_input` that is correct but applied twice, or correct in
storage and re-prefixed on the way into the prompt, would put "You I ..."
back in front of the model — which is the thing the defect was about. So
this asserts on the assembled prompt, not on the stored row.
"""
_play(client, "I enter the tavern.")
_play(client, "I ask Mara about Edrin.")
stored = [a["text"] for a in
client.get(f"/api/adventures/{client.adv_id}/actions").json()["actions"]]
player_rows = [t for t in stored if t.startswith(">")]
assert player_rows, stored
for row in player_rows:
assert "You I" not in row, row
assert row.count(">") == 1, row
report = client.get(f"/api/adventures/{client.adv_id}/context").json()
prompt = "\n".join(s["text"] for s in report["sections"])
assert "You I " not in prompt, "the model's context was polluted with 'You I ...'"
# Each player line appears once, with its marker, in the replayed history.
for row in player_rows:
assert prompt.count(row) == 1, f"{row!r} appears {prompt.count(row)} times"
+331
View File
@@ -0,0 +1,331 @@
"""M9's migration claim, proved against a database M8's own code wrote.
# from the M8 worktree, using M8's interpreter:
python -m tools.m9_migration_proof build <db_path>
# from the M9 tree, using M9's interpreter:
python -m tools.m9_migration_proof open <db_path>
M9 claims to add no schema change. `git diff` proves that nothing in
`migrations.py` or `models.py` moved, which is necessary and not sufficient: a
migration can also be *missing*, and the failure then is a database that opens
and quietly answers wrongly. The M8 report set the standard here — a database
created by today's code and read by today's code proves nothing — so the
campaign below is built by a server running the signed M8 commit, from a git
worktree, and read back by M9.
`build` writes a campaign that touches every family M9 changed the handling of:
story with an alternate take, a Save Point, a manual state correction, memories
and a summary, imported knowledge including a disabled and a narrator-only
source, and per-turn context snapshots. It prints what it wrote, as JSON.
`open` opens that file with the current code, runs the migration path, and
checks every one of those against what `build` reported. It also asserts the
schema version did not move and that a second open is a no-op, which is what
"no migration" means in practice: the stamp is the same number before and after.
Neither half imports anything from the other. What crosses is the database file
and one JSON report on stdout, which is the only way the two builds can be made
to talk without one of them importing the other's code.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "tests"))
def _app(db_path: str):
"""Imports the application against `db_path`. Must run before any app import."""
os.environ["AIDND_DB_PATH"] = db_path
os.environ.pop("AIDND_DATABASE_URL", None)
os.environ.pop("DATABASE_URL", None)
from fastapi import Depends
from fastapi.testclient import TestClient
from app import auth, limits, memorybank, models
from app.database import Base, SessionLocal, engine, get_db
from app.main import app
from app.routers import adventures
from fakes import ScriptedProvider, state_block
class Stub:
async def complete(self, system, prompt, **kwargs):
return "A memory of what had happened by then."
async def embed(self, texts):
return [[1.0, 0.5, 0.25] for _ in texts]
adventures.turns.OpenAICompatibleProvider = ScriptedProvider
memorybank.embedding_provider = lambda s: Stub()
memorybank.summary_provider = lambda s: Stub()
limits.check_row_cap = lambda *a, **k: None
return {
"Base": Base, "SessionLocal": SessionLocal, "engine": engine,
"models": models, "app": app, "auth": auth, "get_db": get_db,
"Depends": Depends, "TestClient": TestClient,
"ScriptedProvider": ScriptedProvider, "state_block": state_block,
"memorybank": memorybank,
}
def _client(ctx, user_id: int):
ctx["app"].dependency_overrides[ctx["auth"].get_current_user] = (
lambda db=ctx["Depends"](ctx["get_db"]): db.get(ctx["models"].User, user_id)
)
return ctx["TestClient"](ctx["app"])
# ------------------------------------------------------------------- building
def build(db_path: str) -> dict:
ctx = _app(db_path)
ctx["Base"].metadata.create_all(bind=ctx["engine"])
models, SessionLocal = ctx["models"], ctx["SessionLocal"]
db = SessionLocal()
try:
user = models.User(is_guest=False, email="m9mig@example.com")
db.add(user)
db.flush()
db.add(models.Settings(
user_id=user.id, model="m8-model", embedding_model="stub",
context_token_budget=4000, max_output_tokens=400,
))
adventure = models.Adventure(
user_id=user.id, title="Built by M8", auto_summarize=True,
memory_bank_enabled=True,
campaign_canon={"rules": ["The dead do not return."]},
)
db.add(adventure)
db.flush()
db.add(models.Action(
adventure_id=adventure.id, type="start",
text="Aldric sits in the Crooked Lantern with Mara.",
))
db.commit()
adv_id, user_id = adventure.id, user.id
finally:
db.close()
client = _client(ctx, user_id)
upload = client.post(
f"/api/adventures/{adv_id}/knowledge",
files={"file": ("canon.md", (
"# Westhaven\n\n## The Old Abbey\n\nThe abbey crypt is sealed.\n"
).encode(), "text/markdown")},
data={"classification": "canon", "always_include": "true"},
)
assert upload.status_code == 201, upload.text[:300]
hidden = client.post(
f"/api/adventures/{adv_id}/knowledge",
files={"file": ("secret.md", (
"# The seal\n\nIt was broken once, sixty years ago.\n"
).encode(), "text/markdown")},
data={"classification": "canon", "visibility": "hidden"},
)
assert hidden.status_code == 201, hidden.text[:300]
disabled = client.post(
f"/api/adventures/{adv_id}/knowledge",
files={"file": ("draft.md", b"# Draft\n\nAn earlier version.\n",
"text/markdown")},
data={"classification": "reference"},
)
assert disabled.status_code == 201, disabled.text[:300]
assert client.patch(
f"/api/adventures/{adv_id}/knowledge/{disabled.json()['id']}",
json={"enabled": False},
).status_code == 200
state_block = ctx["state_block"]
for turn in range(1, 8):
ctx["ScriptedProvider"].replies = [
f"The rain keeps on, and Mara says nothing for a while. [{turn}]\n"
+ state_block([{"type": "add_fact", "predicate": "tally",
"value": turn * 10, "fact_id": f"tally-{turn * 10}"}])
]
response = client.post(f"/api/adventures/{adv_id}/actions",
json={"type": "do", "text": f"ask about turn {turn}"})
assert response.status_code == 200, response.text[:300]
if turn == 3:
assert client.post(f"/api/adventures/{adv_id}/retry").status_code == 200
point = client.post(f"/api/adventures/{adv_id}/checkpoints",
json={"name": "Third turn", "note": "A position."})
assert point.status_code == 201, point.text[:300]
correction = client.post(f"/api/adventures/{adv_id}/state/corrections", json={
"events": [{"type": "add_fact", "predicate": "keeper", "value": "Mara",
"fact_id": "keeper"}],
"note": "Established in play.",
})
assert correction.status_code == 201, correction.text[:300]
import asyncio
asyncio.run(ctx["memorybank"].run_post_turn(adv_id))
# Two Undos, so the head is behind the retained tip when M9 opens it.
for _ in range(2):
assert client.post(f"/api/adventures/{adv_id}/undo").status_code == 200
report = _describe(ctx, client, adv_id)
ctx["app"].dependency_overrides.clear()
return report
# -------------------------------------------------------------------- reading
def _describe(ctx, client, adv_id: int) -> dict:
"""Everything the other build has to agree with, read through the API."""
models, SessionLocal = ctx["models"], ctx["SessionLocal"]
page = client.get(f"/api/adventures/{adv_id}").json()
with SessionLocal() as db:
adventure = db.get(models.Adventure, adv_id)
version = db.execute(_pragma()).scalar()
counts = {
name: db.query(model).filter(model.adventure_id == adv_id).count()
for name, model in (
("actions", models.Action), ("memories", models.Memory),
("summaries", models.Summary), ("checkpoints", models.Checkpoint),
("state_events", models.StateEvent),
("state_proposals", models.StateProposal),
("knowledge_sources", models.KnowledgeSource),
("knowledge_chunks", models.KnowledgeChunk),
)
}
head = {"branch_id": adventure.head_branch_id, "depth": adventure.head_depth}
snapshots = db.query(models.Action).filter(
models.Action.adventure_id == adv_id,
models.Action.context_snapshot.isnot(None),
).count()
return {
"adventure_id": adv_id,
"schema_version": version,
"title": page["title"],
"canon_rules": page["canon_rules"],
"transcript": [a["text"] for a in page["actions"]],
"can_undo": page["can_undo"],
"can_redo": page["can_redo"],
"head": head,
"counts": counts,
"snapshot_rows": snapshots,
"state": client.get(f"/api/adventures/{adv_id}/state").json()["document"],
"checkpoints": sorted(
(c["name"], c["depth"])
for c in client.get(f"/api/adventures/{adv_id}/checkpoints").json()
),
"knowledge": sorted(
(k["original_filename"], k["classification"], k["enabled"],
k["visibility"], k["always_include"], k["content_hash"],
k["index_state"])
for k in client.get(f"/api/adventures/{adv_id}/knowledge").json()
),
"events": sorted(
(e["event_type"], e["source"], json.dumps(e["payload"], sort_keys=True))
for e in client.get(
f"/api/adventures/{adv_id}/state/events?limit=500").json()
),
}
def _comparable(value):
"""`value` as it survives a JSON round trip, so the two builds compare like."""
return json.loads(json.dumps(value, sort_keys=True, default=str))
def _pragma():
from sqlalchemy import text
return text("PRAGMA user_version")
def open_it(db_path: str, expected: dict) -> dict:
"""Opens an existing database with this build, and checks it against `expected`."""
ctx = _app(db_path)
from app import migrations
# This is the migration run. `main` already called `bootstrap` at import.
with ctx["engine"].begin() as conn:
after_first = conn.execute(_pragma()).scalar()
# And again, to prove idempotence: a second run must change nothing.
migrations.bootstrap(ctx["engine"])
with ctx["engine"].begin() as conn:
after_second = conn.execute(_pragma()).scalar()
adv_id = expected["adventure_id"]
with ctx["SessionLocal"]() as db:
user = db.query(ctx["models"].User).first()
user_id = user.id
client = _client(ctx, user_id)
actual = _describe(ctx, client, adv_id)
problems = []
for key in ("title", "canon_rules", "transcript", "head", "counts",
"snapshot_rows", "state", "checkpoints", "knowledge", "events",
"can_undo", "can_redo"):
# Compared through JSON, because that is how the other build's answer
# arrived: a tuple written by `_describe` comes back as a list, and a
# comparison that called that a difference would report ten differences
# in a database nothing had changed.
if _comparable(actual[key]) != _comparable(expected[key]):
problems.append(f"{key}: expected {expected[key]!r}, got {actual[key]!r}")
if expected["schema_version"] != after_first:
problems.append(
f"the schema version moved: {expected['schema_version']} -> {after_first}"
)
if after_first != after_second:
problems.append(
f"a second open migrated again: {after_first} -> {after_second}"
)
# And the campaign still works, rather than merely reading correctly.
exported = client.get(f"/api/adventures/{adv_id}/export")
if exported.status_code != 200:
problems.append(f"export failed: {exported.status_code}")
else:
imported = client.post("/api/adventures/import", json=exported.json())
if imported.status_code != 201:
problems.append(f"round trip failed: {imported.text[:300]}")
elif exported.json()["format"] != "ai-dnd-adventure-v3":
problems.append("the M8 database did not export as v3")
redo = client.post(f"/api/adventures/{adv_id}/redo")
if redo.status_code != 200:
problems.append(f"Redo failed on the migrated campaign: {redo.status_code}")
ctx["app"].dependency_overrides.clear()
return {
"schema_version_before": expected["schema_version"],
"schema_version_after": after_first,
"schema_version_second_open": after_second,
"problems": problems,
"checked": {
"families": 12, "snapshot_rows": actual["snapshot_rows"],
"counts": actual["counts"],
},
}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("mode", choices=("build", "open"))
parser.add_argument("db_path")
parser.add_argument("--expected", help="the JSON `build` printed (open only)")
args = parser.parse_args()
if args.mode == "build":
print(json.dumps(build(args.db_path), sort_keys=True))
return 0
expected = json.loads(Path(args.expected).read_text())
result = open_it(args.db_path, expected)
print(json.dumps(result, indent=2, sort_keys=True))
return 1 if result["problems"] else 0
if __name__ == "__main__":
raise SystemExit(main())
+354
View File
@@ -0,0 +1,354 @@
"""Measures what a campaign bundle preserves, omits and rebuilds.
python -m tools.m9_portability_report # human-readable
python -m tools.m9_portability_report --json # machine-readable
Run from `backend/`, with the virtualenv on the path. The script builds the M9
portability fixture in a throwaway database, exports it, imports it into a second
throwaway database, and then compares the two campaigns family by family.
It exists because the M9 brief asks for the baseline to be **measured** rather
than assumed. Running it on the M8 commit produces the inventory M9 started from;
running it on the M9 tree produces the one M9 finished with, and the difference
between the two files is the milestone's portability claim in a form a reviewer
can reproduce rather than take on trust.
The comparison is by data family rather than by row count. "12 actions in, 12
actions out" is the check that misses a bundle carrying every turn and none of
its state, so each family below reports what a reader could still see afterwards.
Nothing here touches the developer's own database: two temporary files are
created and removed, and no network call is made — the narrator, the summariser
and the embedder are all local fakes.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import tempfile
import time
from pathlib import Path
# The test harness owns the fixture and the fakes. Both live under `tests/`,
# which is not a package, so the path is extended rather than imported from.
_HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(_HERE.parent / "tests"))
# `app.database` reads this at import and builds the engine once, exactly as
# `tests/conftest.py` explains. It has to be set before the first `app` import.
_SOURCE_DB = tempfile.NamedTemporaryFile(suffix="-m9-source.db", delete=False)
_SOURCE_DB.close()
os.environ["AIDND_DB_PATH"] = _SOURCE_DB.name
os.environ.pop("AIDND_DATABASE_URL", None)
os.environ.pop("DATABASE_URL", None)
from fastapi import Depends # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
import m9_fixture # noqa: E402
from app import auth, limits, memorybank, models # noqa: E402
from app.database import Base, SessionLocal, engine, get_db # noqa: E402
from app.main import app # noqa: E402
from app.routers import adventures # noqa: E402
from fakes import ScriptedProvider # noqa: E402
class _StubDerivedProvider:
"""Deterministic vectors and prose, so the report needs no model at all.
One object serves as both the embedder and the summariser, because the
memory pass builds each from the same factory and stubbing only one of them
is the M6 finding M6-F3 mistake: the unstubbed factory opens a socket
against the default endpoint on every turn.
"""
_written = 0
async def complete(self, system, prompt, **kwargs):
_StubDerivedProvider._written += 1
return (
f"Memory {_StubDerivedProvider._written}: what the story had "
f"established by this point."
)
async def embed(self, texts):
out = []
for text in texts:
lowered = text.lower()
out.append([
1.0,
1.0 if "abbey" in lowered or "crypt" in lowered else 0.0,
1.0 if "tavern" in lowered or "lantern" in lowered else 0.0,
1.0 if "rain" in lowered else 0.0,
])
return out
def _install_fakes() -> None:
adventures.turns.OpenAICompatibleProvider = ScriptedProvider
memorybank.embedding_provider = lambda s: _StubDerivedProvider()
memorybank.summary_provider = lambda s: _StubDerivedProvider()
limits.check_row_cap = lambda *a, **k: None
def _new_user_and_campaign(title: str) -> tuple[int, int]:
db = SessionLocal()
try:
user = models.User(is_guest=False, email=f"m9-{title}@example.com")
db.add(user)
db.flush()
db.add(models.Settings(
user_id=user.id, model="report-model", embedding_model="stub-embed",
context_token_budget=4000, max_output_tokens=400,
))
adventure = models.Adventure(
user_id=user.id, title=title,
campaign_canon=m9_fixture.CAMPAIGN_CANON,
)
db.add(adventure)
db.flush()
db.add(models.Action(
adventure_id=adventure.id, type="start", text=m9_fixture.OPENING,
))
# A neighbour, so a bundle that reached past its own campaign would
# bring back rows this report can see.
neighbour = models.Adventure(user_id=user.id, title="Neighbour")
db.add(neighbour)
db.flush()
db.add(models.Action(
adventure_id=neighbour.id, type="start", text="A different story.",
))
db.commit()
return adventure.id, user.id
finally:
db.close()
def _client(user_id: int) -> TestClient:
app.dependency_overrides[auth.get_current_user] = (
lambda db=Depends(get_db): db.get(models.User, user_id)
)
return TestClient(app)
# ----------------------------------------------------------------- the families
# One entry per data family the M9 brief asks the baseline to classify. Each
# `present` function answers "did this survive into the file?" from the bundle
# alone, because that is the question the classification is about.
def _actions(bundle: dict) -> list[dict]:
return [a for a in (bundle.get("actions") or []) if isinstance(a, dict)]
def _snapshots(bundle: dict) -> list[dict]:
"""Every stored prompt in the file, decoded.
The export compresses them (`bundle._packed`), so a report that looked for a
plain dict would say the evidence was omitted when it is merely encoded —
which is the mistake this whole tool exists to avoid making about anything.
"""
from app import bundle as bundle_module
out = []
for action in _actions(bundle):
snapshot = (
action.get("contextSnapshot")
if isinstance(action.get("contextSnapshot"), dict)
else bundle_module._unpacked(action.get("contextSnapshotZ"))
)
if isinstance(snapshot, dict):
out.append(snapshot)
return out
FAMILIES: list[tuple[str, str, callable]] = [
("campaign identity",
"title, instructions, persona, canon, the campaign's own settings",
lambda b: bool(b.get("title"))),
("transcript",
"every accepted player and narrator action, live and superseded",
lambda b: bool(_actions(b))),
("branches",
"the retained tree, its fork points and its names",
lambda b: bool(b.get("branches"))),
("branch disposition",
"which lines the story left behind, and where",
lambda b: any("supersededAt" in x for x in (b.get("branches") or []))),
("active head",
"the branch and depth the campaign is being read at",
lambda b: b.get("headDepth") is not None),
("alternate takes",
"every attempt at a turn, and which one is the story",
lambda b: any(not a.get("live", True) for a in _actions(b))),
("take grouping",
"which attempts belong to the same turn across a fork (SP9 parentage)",
lambda b: any("parentId" in a for a in _actions(b))),
("save points",
"named coordinates, their notes and their positions",
lambda b: bool(b.get("checkpoints"))),
("narrative state (current)",
"the authoritative document at the exported head",
lambda b: b.get("narrativeState") is not None),
("narrative state (per position)",
"the snapshot every position restores from",
lambda b: any("narrativeStateAfter" in a for a in _actions(b))),
("state events",
"the accepted typed events: the audit half of the hybrid",
lambda b: bool(b.get("stateEvents"))),
("state proposals",
"what the model proposed and what the application did about it",
lambda b: bool(b.get("stateProposals"))),
("manual corrections",
"state the user asserted, distinguishable from state the story did",
lambda b: any(e.get("source") == "manual_correction"
for e in (b.get("stateEvents") or []))),
("historical prompt/context",
"the exact prompt each turn was given",
lambda b: bool(_snapshots(b))),
("retrieval provenance",
"which passages a historical turn was shown, and their text",
lambda b: any((s.get("knowledge") or {}).get("used") for s in _snapshots(b))),
("per-turn model settings",
"the model and generation settings a historical turn ran under",
lambda b: any(s.get("settings") for s in _snapshots(b))),
("imported knowledge",
"source content, class, lifecycle, visibility and hash",
lambda b: bool(b.get("knowledge"))),
("knowledge parser versions",
"what produced the chunks the source last had",
lambda b: any("parserVersion" in k for k in (b.get("knowledge") or []))),
("summaries",
"the generated rolling summaries and the story they cover",
lambda b: bool(b.get("summaries"))),
("memories",
"long-term memories and the coordinate each hangs off",
lambda b: bool(b.get("memories"))),
("memory authority",
"whether a memory is accepted story or a heuristic reading of it",
lambda b: any("authority" in m for m in (b.get("memories") or []))),
("scene metadata",
"the scene section of the authoritative state document",
lambda b: isinstance(b.get("narrativeState"), dict)
and "scene" in b["narrativeState"]),
("story cards (legacy)",
"the inherited lore primitive, which has no v1 browser surface",
lambda b: "storyCards" in b),
]
#: Families that are deliberately rebuilt rather than carried, with the reason.
REBUILDABLE = {
"knowledge passages": "a deterministic function of the source content",
"lexical (FTS) index": "rebuilt from the passages on import",
"knowledge embeddings": "belong to the importing machine's embedding model",
"memory embeddings": "the same, for the memory bank",
"branch lineage cache": "computed from parent plus fork depth",
"derived status": "describes the last run of a background pass, not the story",
}
def measure(json_out: bool) -> dict:
_install_fakes()
Base.metadata.create_all(bind=engine)
adv_id, user_id = _new_user_and_campaign("M9 Portability Fixture")
client = _client(user_id)
built = time.perf_counter()
source = m9_fixture.build(client, adv_id)
build_seconds = time.perf_counter() - built
started = time.perf_counter()
response = client.get(f"/api/adventures/{adv_id}/export")
export_seconds = time.perf_counter() - started
response.raise_for_status()
bundle = response.json()
encoded = json.dumps(bundle, ensure_ascii=False).encode("utf-8")
started = time.perf_counter()
imported = client.post("/api/adventures/import", json=bundle)
import_seconds = time.perf_counter() - started
import_status = imported.status_code
copy = (
m9_fixture.snapshot_of(client, imported.json()["id"])
if import_status == 201 else None
)
source_db_bytes = Path(_SOURCE_DB.name).stat().st_size
app.dependency_overrides.clear()
families = [
{"family": name, "what": what,
"verdict": "PRESERVED" if present(bundle) else "OMITTED"}
for name, what, present in FAMILIES
]
report = {
"format": bundle.get("format"),
"families": families,
"rebuildable": REBUILDABLE,
"sizes": {
"source_database_bytes": source_db_bytes,
"bundle_bytes": len(encoded),
"bundle_actions": len(_actions(bundle)),
"bundle_keys": sorted(bundle),
},
"timings_seconds": {
"fixture_build": round(build_seconds, 3),
"export": round(export_seconds, 3),
"import": round(import_seconds, 3),
},
"round_trip": {
"import_status": import_status,
"agrees": _agreement(source, copy) if copy else None,
},
}
return report
def _agreement(source: dict, copy: dict) -> dict:
"""Which of the reader-visible families match between original and copy."""
keys = ("title", "canon_rules", "transcript", "branch_count", "checkpoints",
"knowledge", "state", "state_events", "memories", "summaries",
"can_undo", "can_redo")
return {key: source.get(key) == copy.get(key) for key in keys}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--json", action="store_true",
help="print the report as JSON")
args = parser.parse_args()
try:
report = measure(args.json)
finally:
for path in (_SOURCE_DB.name,):
try:
os.unlink(path)
except OSError:
pass
if args.json:
print(json.dumps(report, indent=2, sort_keys=True))
return 0
print(f"bundle format: {report['format']}")
print(f"bundle size: {report['sizes']['bundle_bytes']:,} bytes "
f"across {report['sizes']['bundle_actions']} actions")
print(f"source db: {report['sizes']['source_database_bytes']:,} bytes")
print(f"timings: {report['timings_seconds']}")
print()
width = max(len(name) for name, _, _ in FAMILIES)
for row in report["families"]:
print(f" {row['verdict']:<10} {row['family']:<{width}} {row['what']}")
print()
print(" DERIVED/REBUILDABLE (deliberately not carried)")
for name, why in REBUILDABLE.items():
print(f" {name:<24} {why}")
print()
print(f"round trip: HTTP {report['round_trip']['import_status']}")
for key, agreed in (report["round_trip"]["agrees"] or {}).items():
print(f" {'same' if agreed else 'DIFFERS':<8} {key}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+264
View File
@@ -0,0 +1,264 @@
"""How a campaign bundle grows with the campaign, measured rather than reasoned.
python -m tools.m9_scale_report [--turns 120] [--budget 16384]
Run from `backend/`. Plays a campaign of `--turns` turns against a scripted
narrator with the real prompt builder and a realistic context budget, then
exports it and reports where the bytes are.
The question it exists to answer is the one M9's decision to carry historical
prompts raises: **a per-turn prompt contains the story so far, so storing one per
turn is quadratic in campaign length.** That is already true of the database —
`compression.py` records the column as 89% of production storage — and M9 makes
it true of the export as well. Reasoning about it gives the wrong number, because
the prompt is bounded by the context budget rather than by the transcript: once
the history window is full, each turn's snapshot stops growing and the total
becomes linear again. Where that knee falls is a measurement.
It also watches for the accidental costs §26 names: a query per row, a
duplicated body of knowledge content, or a snapshot written more than once per
turn.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import tempfile
import time
from pathlib import Path
_HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(_HERE.parent / "tests"))
_DB = tempfile.NamedTemporaryFile(suffix="-m9-scale.db", delete=False)
_DB.close()
os.environ["AIDND_DB_PATH"] = _DB.name
os.environ.pop("AIDND_DATABASE_URL", None)
os.environ.pop("DATABASE_URL", None)
from fastapi import Depends # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
import m9_fixture # noqa: E402
from app import auth, limits, memorybank, models # noqa: E402
from app.database import Base, SessionLocal, engine, get_db # noqa: E402
from app.main import app # noqa: E402
from app.routers import adventures # noqa: E402
from fakes import ScriptedProvider, state_block # noqa: E402
#: Prose long enough that a turn is a turn rather than a sentence. The history
#: window is what fills the prompt, so a fixture of three-word replies would
#: measure a campaign nobody plays.
PROSE = (
"The rain came harder off the fen and the lantern light shivered on the wet "
"boards. Mara set down the cloth she had been folding and looked at him for "
"a while without saying anything, the way she did when the answer was going "
"to cost her something. Outside, somebody crossed the yard and did not stop."
)
class _Stub:
async def complete(self, system, prompt, **kwargs):
return "The story had established a good deal by this point."
async def embed(self, texts):
return [[1.0, 0.5, 0.25, 0.125] for _ in texts]
def _setup(budget: int) -> tuple[TestClient, int]:
adventures.turns.OpenAICompatibleProvider = ScriptedProvider
memorybank.embedding_provider = lambda s: _Stub()
memorybank.summary_provider = lambda s: _Stub()
limits.check_row_cap = lambda *a, **k: None
Base.metadata.create_all(bind=engine)
db = SessionLocal()
try:
user = models.User(is_guest=False, email="scale@example.com")
db.add(user)
db.flush()
db.add(models.Settings(
user_id=user.id, model="scale-model", embedding_model="stub",
context_token_budget=budget, max_output_tokens=800,
))
adventure = models.Adventure(
user_id=user.id, title="Scale", auto_summarize=True,
memory_bank_enabled=True,
campaign_canon=m9_fixture.CAMPAIGN_CANON,
)
db.add(adventure)
db.flush()
db.add(models.Action(
adventure_id=adventure.id, type="start", text=m9_fixture.OPENING,
))
db.commit()
adv_id, user_id = adventure.id, user.id
finally:
db.close()
app.dependency_overrides[auth.get_current_user] = (
lambda db=Depends(get_db): db.get(models.User, user_id)
)
return TestClient(app), adv_id
def _bundle_bytes(client, adv_id) -> tuple[int, dict, float]:
started = time.perf_counter()
response = client.get(f"/api/adventures/{adv_id}/export")
seconds = time.perf_counter() - started
response.raise_for_status()
payload = response.json()
return len(json.dumps(payload).encode("utf-8")), payload, seconds
def _snapshot_bytes(payload: dict) -> int:
"""What the stored prompts cost **in the file**, which is the encoded size.
Measured as they appear rather than decoded first: the question this report
answers is how large the file gets and how close it comes to the import
ceiling, so what counts is the bytes that actually travel.
"""
return sum(
len(json.dumps(action[key]).encode("utf-8"))
for action in payload["actions"]
for key in ("contextSnapshotZ", "contextSnapshot")
if action.get(key)
)
def _decoded_snapshot_bytes(payload: dict) -> int:
"""What the same prompts would cost uncompressed, for the ratio."""
from app import bundle as bundle_module
total = 0
for action in payload["actions"]:
snapshot = (
action.get("contextSnapshot")
if isinstance(action.get("contextSnapshot"), dict)
else bundle_module._unpacked(action.get("contextSnapshotZ"))
)
if isinstance(snapshot, dict):
total += len(json.dumps(snapshot).encode("utf-8"))
return total
def _section_bytes(payload: dict) -> dict:
"""What each v3 addition costs in the file, separately.
Needed because "the snapshots are 21% of the file" does not answer "what did
M9 add": the state events, the proposals and the summaries are v3 additions
too, and a claim about M9's cost that counted only the prompts would be
understating it.
"""
def size(value) -> int:
return len(json.dumps(value).encode("utf-8"))
per_node = {"contextSnapshotZ": 0, "id": 0, "parentId": 0}
for action in payload["actions"]:
for key in per_node:
if key in action:
per_node[key] += size(action[key]) + len(key) + 4
return {
"prompts (contextSnapshotZ)": per_node["contextSnapshotZ"],
"state events": size(payload.get("stateEvents") or []),
"state proposals": size(payload.get("stateProposals") or []),
"summaries": size(payload.get("summaries") or []),
"node ids + parentage": per_node["id"] + per_node["parentId"],
"per-position state (v2 already)": sum(
size(a["narrativeStateAfter"]) for a in payload["actions"]
if "narrativeStateAfter" in a
),
}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--turns", type=int, default=120)
parser.add_argument("--budget", type=int, default=16384)
parser.add_argument("--every", type=int, default=20,
help="report the running size every N turns")
args = parser.parse_args()
client, adv_id = _setup(args.budget)
for name, body, kind in (
("canon.md", m9_fixture.CANON_MD, "canon"),
("reference.md", m9_fixture.REFERENCE_MD, "reference"),
("secret.md", m9_fixture.SECRET_MD, "canon"),
):
m9_fixture.upload(client, adv_id, name, body, kind)
print(f"budget {args.budget} tokens, {args.turns} turns\n")
print(f"{'turns':>6} {'actions':>8} {'bundle B':>12} {'snapshots B':>13} "
f"{'B/turn':>9} {'export s':>9} {'import s':>9}")
rows = []
sections: dict[int, dict] = {}
for turn in range(1, args.turns + 1):
ScriptedProvider.replies = [
f"{PROSE} [{turn}]\n"
+ state_block([{"type": "add_fact", "predicate": "tally",
"value": turn * 10, "fact_id": f"tally-{turn}"}])
]
response = client.post(f"/api/adventures/{adv_id}/actions",
json={"type": "do", "text": f"press on, {turn}"})
assert response.status_code == 200, response.text[:300]
if turn % args.every and turn != args.turns:
continue
m9_fixture.settle_derived(adv_id)
size, payload, export_seconds = _bundle_bytes(client, adv_id)
started = time.perf_counter()
imported = client.post("/api/adventures/import", json=payload)
import_seconds = time.perf_counter() - started
assert imported.status_code == 201, imported.text[:300]
client.delete(f"/api/adventures/{imported.json()['id']}")
snapshots = _snapshot_bytes(payload)
plain = _decoded_snapshot_bytes(payload)
rows.append((turn, size, snapshots, plain))
sections[turn] = _section_bytes(payload)
print(f"{turn:>6} {len(payload['actions']):>8} {size:>12,} "
f"{snapshots:>13,} {snapshots // turn:>9,} "
f"{export_seconds:>9.3f} {import_seconds:>9.3f}")
db_bytes = Path(_DB.name).stat().st_size
last_turn, last_size, last_snapshots, last_plain = rows[-1]
per_turn = last_snapshots // last_turn
cap = limits.MAX_IMPORT_BODY_BYTES
print()
print(f"database on disk: {db_bytes:,} bytes")
print(f"snapshot share of file: {100 * last_snapshots // last_size}%")
print(f"stored uncompressed: {last_plain:,} bytes "
f"({last_plain / max(last_snapshots, 1):.1f}x the encoded size)")
print(f"import body cap: {cap:,} bytes")
print(f"turns before the cap: ~{cap // max(per_turn, 1):,} "
f"at the marginal rate above")
# Growth between the last two samples says whether the per-turn cost has
# settled. It should: once the history window fills the budget, a prompt
# stops growing with the transcript and the total becomes linear.
if len(rows) >= 2:
(t0, _, s0, _p0), (t1, _, s1, _p1) = rows[-2], rows[-1]
print(f"marginal cost, last {t1 - t0} turns: "
f"{(s1 - s0) // max(t1 - t0, 1):,} bytes/turn")
print()
print("where the bytes are, at the last sample:")
last = sections[last_turn]
added = sum(v for k, v in last.items() if not k.endswith("(v2 already)"))
for name, value in sorted(last.items(), key=lambda kv: -kv[1]):
print(f" {name:34} {value:>12,} {100 * value / last_size:5.1f}%")
print(f" {'--- everything v3 added':34} {added:>12,} "
f"{100 * added / last_size:5.1f}%")
without = last_size - added
print(f" a v2 file of the same campaign {without:>12,}")
print(f" ceiling with v3 additions: ~{int((cap / (last_size / last_turn**2)) ** 0.5):,} turns")
print(f" ceiling without them: ~{int((cap / (without / last_turn**2)) ** 0.5):,} turns")
app.dependency_overrides.clear()
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
finally:
try:
os.unlink(_DB.name)
except OSError:
pass
+2352 -15
View File
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -7,7 +7,9 @@
"dev": "vite",
"build": "vite build",
"lint": "oxlint",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"react": "^19.2.7",
@@ -15,10 +17,15 @@
"react-router-dom": "^7.18.1"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.7",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"jsdom": "^26.1.0",
"oxlint": "^1.71.0",
"vite": "^8.1.1"
"vite": "^8.1.1",
"vitest": "^3.2.7"
}
}
+59 -43
View File
@@ -1,50 +1,66 @@
import { useState } from 'react'
import { NavLink, Outlet } from 'react-router-dom'
import { NavLink, Outlet, useMatch } from 'react-router-dom'
import { ToastHost } from './components'
import Embers from './Embers.jsx'
import { ModelStatusProvider } from './modelStatus'
import { ModelStatusBadge } from './ModelStatusBadge'
// The shell: the nav bar and whatever page is routed under it.
//
// Upstream also carried the hosted deployment's account furniture here — a
// /auth/me lookup on mount, guest and sign-up prompts, a log-out button, a
// pageview beacon, and two nav links gated on server-side allowlists. M2
// removed the deployment those served. There is one local user, nothing to log
// in to, and nothing counting.
/* The shell: a thin top bar and whatever page is routed under it.
*
* M8 reduced this to the two places that are not inside a campaign. Upstream
* carried five links — Home, Adventures, Scenarios, Settings, AI Chat — which
* described AI-DnD's information architecture rather than this product's.
* `BROWSER-UX-SPEC.md` §97 has one entry point (the campaign library) and puts
* everything else *inside* a campaign, where it belongs: a Save Point, a piece
* of knowledge and a story state are all things a campaign has, and none of
* them mean anything at the top level.
*
* What went, and why:
*
* Adventures the library is now the landing page, so a second link to a
* second list of the same thing had nothing to point at.
* Scenarios a scenario is a template for a campaign. The editor for one
* was a schema editor, a story-card table and an art picker —
* §93's "dangerous advanced features" almost exactly. Creating a
* campaign no longer requires one.
* AI Chat a raw model console. Useful to whoever was debugging the
* fork; nothing to do with telling a story.
*
* The story screen deliberately does *not* show this bar's campaign links. It
* has its own header, because on the one screen that matters the story is the
* interface.
*/
export default function App() {
const [navOpen, setNavOpen] = useState(false) // mobile hamburger menu
// The story screen owns its whole viewport, so the shell gets out of the way.
const playing = useMatch('/play/:id')
return (
<ToastHost>
<Embers />
<nav className="topnav">
<span className="brand">⚔ Adventure Storyteller</span>
<button
className="nav-hamburger"
aria-label="Menu"
aria-expanded={navOpen}
onClick={() => setNavOpen((o) => !o)}
>
{navOpen ? '✕' : '☰'}
</button>
<div className={`nav-links${navOpen ? ' open' : ''}`} onClick={() => setNavOpen(false)}>
<NavLink to="/" end className={({ isActive }) => `navlink${isActive ? ' active' : ''}`}>
Home
</NavLink>
<NavLink to="/adventures" className={({ isActive }) => `navlink${isActive ? ' active' : ''}`}>
Adventures
</NavLink>
<NavLink to="/scenarios" className={({ isActive }) => `navlink${isActive ? ' active' : ''}`}>
Scenarios
</NavLink>
<NavLink to="/settings" className={({ isActive }) => `navlink${isActive ? ' active' : ''}`}>
Settings
</NavLink>
<NavLink to="/chat" className={({ isActive }) => `navlink${isActive ? ' active' : ''}`}>
AI Chat
</NavLink>
</div>
</nav>
<Outlet />
</ToastHost>
<ModelStatusProvider>
<ToastHost>
<a className="skip-link" href="#main">Skip to main content</a>
{!playing && (
<nav className="topnav" aria-label="Main">
<NavLink to="/" className="brand">Adventure Storyteller</NavLink>
<div className="nav-links">
<NavLink
to="/"
end
className={({ isActive }) => `navlink${isActive ? ' active' : ''}`}
>
Campaigns
</NavLink>
<NavLink
to="/settings"
className={({ isActive }) => `navlink${isActive ? ' active' : ''}`}
>
Settings
</NavLink>
</div>
<ModelStatusBadge />
</nav>
)}
<main id="main">
<Outlet />
</main>
</ToastHost>
</ModelStatusProvider>
)
}
-147
View File
@@ -1,147 +0,0 @@
import { useRef, useState } from 'react'
import { ScenarioArt } from './components'
// Cards render the plate at 46px (92px on a 2x screen), and the editor preview
// at 76px. 400px square gives headroom for both plus any future larger use,
// while keeping the stored data URI in the tens of kilobytes.
const MAX_EDGE = 400
const WEBP_QUALITY = 0.82
// The backend caps the column at 400_000 chars; stay clearly under it so a
// pathological image fails here with a clear message rather than as a 422.
const MAX_DATA_URI = 360_000
// A spread of moods rather than a themed set — most scenarios find something
// close enough here and skip hunting for a picture.
const SUGGESTED_ICONS = [
'⚔️', '🗡️', '🏹', '🛡️', '🔮', '🗝️', '📜', '🏰',
'🐉', '👑', '💀', '🕯️', '🌑', '🌲', '⛰️', '🌊',
'🚀', '🛰️', '🤖', '👁️', '🩸', '🃏',
]
/** Read a File and re-encode it small, as a WebP data URI.
*
* Downscaling in the browser means the upload never leaves the machine at full
* size and the row stays small — no server-side image library required.
*/
function downscaleToDataURI(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onerror = () => reject(new Error('Could not read that file'))
reader.onload = () => {
const img = new Image()
img.onerror = () => reject(new Error('That file is not an image the browser can read'))
img.onload = () => {
const scale = Math.min(1, MAX_EDGE / Math.max(img.width, img.height))
const canvas = document.createElement('canvas')
// Math.max(1, …) guards against a 0-dimension canvas, which throws.
canvas.width = Math.max(1, Math.round(img.width * scale))
canvas.height = Math.max(1, Math.round(img.height * scale))
const ctx = canvas.getContext('2d')
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
let uri = canvas.toDataURL('image/webp', WEBP_QUALITY)
// Safari only gained WebP encoding recently; if it silently fell back
// to PNG, retry as JPEG so we don't store a huge lossless blob.
if (!uri.startsWith('data:image/webp')) {
uri = canvas.toDataURL('image/jpeg', WEBP_QUALITY)
}
if (uri.length > MAX_DATA_URI) {
reject(new Error('That image is too detailed to store — try a smaller or simpler one'))
return
}
resolve(uri)
}
img.src = reader.result
}
reader.readAsDataURL(file)
})
}
/** Cover-art picker: upload a picture, choose an emoji, or leave it generated.
*
* `image` and `icon` are the stored values; `onChange({ image, icon })` gets
* both on every change so the caller can persist them together.
*/
export default function ArtPicker({ title, image, icon, onChange, disabled }) {
const fileRef = useRef(null)
const [error, setError] = useState('')
const pick = async (event) => {
const file = event.target.files?.[0]
// Reset immediately so picking the same file twice still fires onChange.
event.target.value = ''
if (!file) return
setError('')
try {
const uri = await downscaleToDataURI(file)
// An uploaded picture wins over an emoji, so clear the icon to make the
// precedence visible rather than leaving a hidden value behind.
onChange({ image: uri, icon: '' })
} catch (err) {
setError(err.message)
}
}
const chooseIcon = (glyph) => {
setError('')
onChange({ image: '', icon: icon === glyph ? '' : glyph })
}
const clear = () => {
setError('')
onChange({ image: '', icon: '' })
}
return (
<label className="field">
<span className="label">Cover art</span>
<div className="art-picker">
<span className="art-preview">
{/* Same three-tier fallback the cards use, at preview size. */}
<ScenarioArt image={image} icon={icon} title={title} />
</span>
<div className="art-controls">
<div className="art-buttons">
<button type="button" disabled={disabled} onClick={() => fileRef.current?.click()}>
Upload image
</button>
{(image || icon) && (
<button type="button" disabled={disabled} onClick={clear}>Clear</button>
)}
</div>
<input
ref={fileRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif,image/avif"
hidden
onChange={pick}
/>
<div className="icon-row">
{SUGGESTED_ICONS.map((glyph) => (
<button
key={glyph}
type="button"
disabled={disabled}
className={icon === glyph ? 'active' : ''}
title={`Use ${glyph}`}
onClick={() => chooseIcon(glyph)}
>
{glyph}
</button>
))}
</div>
{error ? (
<p className="art-hint" style={{ color: 'var(--danger)' }}>{error}</p>
) : (
<p className="art-hint">
Pictures are shrunk to {MAX_EDGE}px before saving. With neither a picture
nor an emoji, the card draws its own art from the title.
</p>
)}
</div>
</div>
</label>
)
}
-295
View File
@@ -1,295 +0,0 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { CORNER, PAD, ROW_H, branchLabel, headLineage, layoutTree, momentTicks, savePointsUnder } from './branches'
// The story tree, drawn.
//
// The Branches panel lists every line; this draws the same list as lanes on one
// clock, which is the thing a list cannot say — *where* two tellings parted, and
// how much story each of them is. A lane runs from the moment its branch left
// its parent to the moment it ends, so length is story and a fork is a corner.
//
// It reads nothing of its own. Everything here comes from the `GET /branches`
// the panel already made, and every operation goes back through the panel's, so
// there is one copy of the rules and one thing to keep honest.
//
// Portalled to <body>: this is opened from inside `.side-panel`, whose panel-in
// animation (fill mode `both`) makes it the containing block for position:fixed
// children — an overlay rendered in place would be trapped in the 420px panel
// and clipped by its overflow. Same trap for anything opened from a drawer.
// A label is drawn into the space its lane leaves. `perChar` is the average
// width of the face it is drawn in — about 7px for the 15px serif a name uses
// and 5px for the 10.5px UI face under it. Overshooting only costs an ellipsis,
// so this is deliberately a guess rather than a measurement pass.
function clipToWidth(text, px, perChar = 7) {
const max = Math.max(4, Math.floor(px / perChar))
return text.length <= max ? text : `${text.slice(0, max - 1)}…`
}
export function BranchMap({ branches, busyId, onSwitch, onRename, onDelete, onClose }) {
const [selectedId, setSelectedId] = useState(() => branches.find((b) => b.is_head)?.id ?? null)
const [renameText, setRenameText] = useState(null) // null = not renaming
const [confirming, setConfirming] = useState(false)
const [width, setWidth] = useState(0)
const boxRef = useRef(null)
const { lanes, maxDepth, height } = useMemo(() => layoutTree(branches), [branches])
const lineage = useMemo(() => headLineage(branches), [branches])
// The map is drawn in real pixels rather than scaled from a fixed viewBox,
// so a narrow window gets a narrower map and not smaller writing.
//
// Both halves are load-bearing, and each covers the other's gap. The seed
// measures the *content* box, because that is what the observer reports and
// `clientWidth` is not — it counts the canvas padding, so seeding from it
// drew an svg 24px wider than the box it sits in and the map ran off the
// right edge. The observer then keeps up with a window being dragged; it
// cannot be the only source, because its initial observation is not
// guaranteed to arrive and without the seed the map never drew at all.
useLayoutEffect(() => {
const el = boxRef.current
if (!el) return undefined
const style = getComputedStyle(el)
const padding = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight)
setWidth(el.clientWidth - padding)
const observer = new ResizeObserver(([entry]) => setWidth(entry.contentRect.width))
observer.observe(el)
return () => observer.disconnect()
}, [])
// A branch can vanish under the selection — deleting one takes everything
// forked from it, which is more rows than the one that was clicked.
const selected = branches.find((b) => b.id === selectedId) || null
useEffect(() => {
if (!selected) {
setSelectedId(branches.find((b) => b.is_head)?.id ?? null)
setRenameText(null)
setConfirming(false)
}
}, [selected, branches])
useEffect(() => {
const onKey = (e) => {
if (e.key !== 'Escape') return
// Escape backs out of the smallest thing that is open, so it never
// throws away a half-typed name along with the map.
if (renameText !== null) setRenameText(null)
else if (confirming) setConfirming(false)
else onClose()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [renameText, confirming, onClose])
const W = Math.max(width, 320)
const span = Math.max(maxDepth, 1)
// Ticks are spaced by the room there is to print them in, not by a constant.
const ticks = momentTicks(maxDepth, Math.max(3, Math.round(W / 160)))
const x = (depth) => PAD.left + (depth / span) * (W - PAD.left - PAD.right)
const laneY = (row) => PAD.top + row * ROW_H + 30
const pick = (branch) => {
setSelectedId(branch.id)
setRenameText(null)
setConfirming(false)
}
const busy = busyId !== null && busyId !== undefined
// The server refuses to delete the line being read or anything it was forked
// from; saying so on the button is friendlier than a toast after the click.
const isLoadBearing = selected ? lineage.has(selected.id) : false
const isRoot = selected ? selected.parent_branch_id === null : false
// Each of these resolves to whether it worked. The panel owns the request
// and reports its own failures, so all this has to decide is whether to put
// the editor away — clearing a half-typed name on a rename that was refused
// 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}>
<div className="branch-map" onClick={(e) => e.stopPropagation()}
role="dialog" aria-modal="true" aria-label="The story so far">
<div className="branch-map-header">
<h2>The story so far</h2>
<span className="branch-map-scale">
{branches.length} {branches.length === 1 ? 'line' : 'lines'} · {maxDepth + 1} moments
</span>
<button type="button" className="branch-map-close" onClick={onClose} aria-label="Close">✕</button>
</div>
<div className="branch-map-canvas" ref={boxRef}>
{width > 0 && (
<svg className="branch-map-svg" width={W} height={height}
role="img" aria-label={`${branches.length} branches over ${maxDepth + 1} moments`}>
{/* The clock the whole map is read against. */}
{ticks.map((depth) => (
<g key={depth} className="bm-tick">
<line x1={x(depth)} y1={PAD.top - 16} x2={x(depth)} y2={height - PAD.bottom} />
<text x={x(depth)} y={PAD.top - 24} textAnchor="middle">{depth + 1}</text>
</g>
))}
{lanes.map((lane) => {
const child = lane.branch.parent_branch_id !== null && lane.parentRow !== null
const forkX = x(lane.from)
const start = child ? forkX + CORNER : x(lane.from)
// A branch forked but not yet written past still gets a stub,
// or it would be a corner leading to nothing.
const end = Math.max(x(lane.to), start + 8)
const y = laneY(lane.row)
const b = lane.branch
const label = branchLabel(b)
const meta = `${b.own_actions} of its own`
+ (b.parent_branch_id !== null ? ` · left at ${b.fork_depth + 1}` : '')
+ ` · ends at ${b.depth + 1}`
// A lane that leaves late has no room to write in to its right,
// so its labels hang back over the fork instead. The row is its
// own band with nothing else in it, and the alternative is text
// running off the edge — which is what a narrow window did.
const roomRight = W - PAD.right - start
const flip = roomRight < 170 && start - PAD.left > roomRight
const textX = flip ? start - 4 : start
const room = flip ? start - 4 - PAD.left : roomRight
const classes = [
'bm-lane',
b.is_head ? 'here' : '',
b.id === selectedId ? 'picked' : '',
].join(' ')
return (
<g key={b.id} className={classes}
role="button" tabIndex={0}
aria-label={`${label}, ${b.own_actions} of its own${b.is_head ? ', the line you are reading' : ''}`}
onClick={() => pick(b)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick(b) }
}}>
{/* Full-width hit area: the lane itself is 3px tall and a
fork late in a long story is a very small target. */}
<rect className="bm-hit" x={0} y={PAD.top + lane.row * ROW_H}
width={W} height={ROW_H} />
{child && (
<path className="bm-fork"
d={`M ${forkX} ${laneY(lane.parentRow)} L ${forkX} ${y - CORNER} Q ${forkX} ${y} ${forkX + CORNER} ${y}`} />
)}
{child && <circle className="bm-fork-dot" cx={forkX} cy={laneY(lane.parentRow)} r={3.5} />}
<line className="bm-line" x1={start} y1={y} x2={end} y2={y} />
{/* The tip: a diamond for the line being read, so where you
are standing is findable without reading a word. */}
{b.is_head ? (
<rect className="bm-tip" x={end - 5} y={y - 5} width={10} height={10}
transform={`rotate(45 ${end} ${y})`} />
) : (
<circle className="bm-tip" cx={end} cy={y} r={4.5} />
)}
<text className="bm-name" x={textX} y={y - 12}
textAnchor={flip ? 'end' : 'start'}>
{clipToWidth(label, room)}
</text>
<text className="bm-meta" x={textX} y={y + 19}
textAnchor={flip ? 'end' : 'start'}>
{clipToWidth(meta, room, 5)}
</text>
</g>
)
})}
</svg>
)}
</div>
{branches.length === 1 && (
<p className="branch-map-hint">
One thread so far. Retry a turn, then write on from an attempt the
story moved past — that is what makes a second line.
</p>
)}
{selected && (
<div className={`branch-map-detail ${selected.is_head ? 'here' : ''}`}>
<div className="bmd-head">
{renameText !== null ? (
<input
className="branch-rename"
autoFocus
maxLength={80}
value={renameText}
onChange={(e) => setRenameText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') save()
}}
/>
) : (
<span className="branch-name">{branchLabel(selected)}</span>
)}
{selected.is_head && <span className="branch-here">reading</span>}
</div>
<div className="branch-meta">
{selected.own_actions} of its own
{selected.parent_branch_id !== null && ` · forked at moment ${selected.fork_depth + 1}`}
{` · ends at moment ${selected.depth + 1}`}
</div>
{confirming ? (
<div className="branch-confirm">
{/* 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>
<button type="button" onClick={() => setConfirming(false)}>Keep</button>
</div>
) : (
<div className="branch-tools">
{!selected.is_head && (
<button type="button" disabled={busy}
onClick={() => onSwitch(selected)}>Switch to this line</button>
)}
{renameText !== null ? (
<>
<button type="button" disabled={busy} onClick={save}>Save</button>
<button type="button" onClick={() => setRenameText(null)}>Cancel</button>
</>
) : (
<button type="button" disabled={busy}
onClick={() => setRenameText(selected.name || '')}>Rename</button>
)}
{/* The root holds the turns every other branch borrows, and the
server refuses it — so it is not offered at all. A branch
the head stands on is offered, and says why it cannot go. */}
{!isRoot && (
<button type="button" className="danger"
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>
)}
</div>
)}
</div>
</div>,
document.body,
)
}
+155
View File
@@ -0,0 +1,155 @@
/* Modal dialogs, with the focus behaviour §28 requires.
*
* Three things have to be true for a dialog to be usable without a mouse, and
* all three are easy to leave out:
*
* 1. focus moves *into* the dialog when it opens,
* 2. Tab cannot leave it while it is open,
* 3. focus returns to whatever opened it when it closes.
*
* Written by hand rather than with the native `<dialog>` element. `showModal()`
* gives 1-3 for free, but jsdom does not implement it, so every component test
* covering a confirmation would have to stub it out — and a focus trap that is
* stubbed in the tests is a focus trap nothing checks. This version is a plain
* element tree, so the tests exercise the same code the browser runs.
*
* `ConfirmDialog` adds the typed-name confirmation §67 asks for on the one
* destructive action that deserves it.
*/
import { useCallback, useEffect, useId, useRef, useState } from 'react'
const FOCUSABLE = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(',')
export function Dialog({ title, children, onClose, labelledBy, className = '' }) {
const ref = useRef(null)
const returnTo = useRef(null)
const headingId = useId()
const id = labelledBy || headingId
useEffect(() => {
// Remember where focus was, so it can go back there on close.
returnTo.current = document.activeElement
const node = ref.current
if (node) {
const first = node.querySelector(FOCUSABLE)
// The panel itself is focusable as a fallback, so a dialog with no
// controls still moves focus off the page behind it.
;(first || node).focus()
}
return () => {
const back = returnTo.current
if (back && typeof back.focus === 'function' && document.contains(back)) back.focus()
}
}, [])
const onKeyDown = useCallback((event) => {
if (event.key === 'Escape') {
event.stopPropagation()
onClose?.()
return
}
if (event.key !== 'Tab') return
const node = ref.current
if (!node) return
// Hidden controls are skipped by attribute rather than by layout. An
// `offsetParent !== null` check reads more natural and is wrong twice: it
// is null for anything inside a `position: fixed` ancestor, which this
// dialog is, and jsdom never computes it at all — so the trap would behave
// differently in the tests from the browser, which is the one thing a focus
// trap must not do.
const items = Array.from(node.querySelectorAll(FOCUSABLE))
.filter((el) => !el.hidden && el.getAttribute('aria-hidden') !== 'true')
if (items.length === 0) {
event.preventDefault()
return
}
const first = items[0]
const last = items[items.length - 1]
// Wrap at both ends. Without this, Tab off the last control lands on the
// browser chrome and the reader is outside a modal they cannot see they
// have left.
if (event.shiftKey && document.activeElement === first) {
event.preventDefault()
last.focus()
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault()
first.focus()
}
}, [onClose])
return (
<div className="dialog-backdrop" onMouseDown={(e) => {
// Only a click on the backdrop itself closes; a drag that ends there
// after starting inside the panel must not.
if (e.target === e.currentTarget) onClose?.()
}}>
<div
className={`dialog ${className}`}
role="dialog"
aria-modal="true"
aria-labelledby={id}
tabIndex={-1}
ref={ref}
onKeyDown={onKeyDown}
>
<h2 className="dialog-title" id={id}>{title}</h2>
{children}
</div>
</div>
)
}
export function ConfirmDialog({
title,
children,
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
destructive = false,
requireText = null,
requireLabel = 'Type to confirm',
busy = false,
onConfirm,
onCancel,
}) {
const [typed, setTyped] = useState('')
const fieldId = useId()
const ready = requireText === null || typed.trim() === String(requireText).trim()
return (
<Dialog title={title} onClose={onCancel}>
<div className="dialog-body">{children}</div>
{requireText !== null && (
<label className="dialog-field" htmlFor={fieldId}>
<span>{requireLabel}</span>
<input
id={fieldId}
type="text"
value={typed}
autoComplete="off"
onChange={(e) => setTyped(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && ready) onConfirm() }}
/>
</label>
)}
<div className="dialog-actions">
<button type="button" onClick={onCancel} disabled={busy}>{cancelLabel}</button>
<button
type="button"
className={destructive ? 'danger' : 'primary'}
onClick={onConfirm}
disabled={busy || !ready}
>
{busy ? 'Working…' : confirmLabel}
</button>
</div>
</Dialog>
)
}
+157
View File
@@ -0,0 +1,157 @@
/* The dialog's focus behaviour, which is the part §28 actually requires and the
* part that is invisible until someone tries to use the product without a mouse.
*/
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { ConfirmDialog, Dialog } from './Dialog'
describe('focus (§28)', () => {
it('moves focus into the dialog when it opens', async () => {
render(
<Dialog title="A question" onClose={vi.fn()}>
<button type="button">Inside</button>
</Dialog>,
)
expect(screen.getByRole('button', { name: 'Inside' })).toHaveFocus()
})
it('returns focus to whatever opened it', async () => {
const user = userEvent.setup()
function Harness() {
const [open, setOpen] = useState(false)
return (
<>
<button type="button" onClick={() => setOpen(true)}>Open</button>
{open && (
<Dialog title="A question" onClose={() => setOpen(false)}>
<button type="button" onClick={() => setOpen(false)}>Close</button>
</Dialog>
)}
</>
)
}
render(<Harness />)
const opener = screen.getByRole('button', { name: 'Open' })
await user.click(opener)
await user.click(screen.getByRole('button', { name: 'Close' }))
expect(opener).toHaveFocus()
})
it('wraps Tab at both ends rather than letting focus escape', async () => {
const user = userEvent.setup()
render(
<Dialog title="A question" onClose={vi.fn()}>
<button type="button">First</button>
<button type="button">Last</button>
</Dialog>,
)
const first = screen.getByRole('button', { name: 'First' })
const last = screen.getByRole('button', { name: 'Last' })
expect(first).toHaveFocus()
await user.tab()
expect(last).toHaveFocus()
await user.tab()
expect(first).toHaveFocus()
await user.tab({ shift: true })
expect(last).toHaveFocus()
})
it('closes on Escape', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
render(
<Dialog title="A question" onClose={onClose}>
<button type="button">Inside</button>
</Dialog>,
)
await user.keyboard('{Escape}')
expect(onClose).toHaveBeenCalled()
})
it('is announced as a modal dialog with its title as the name', () => {
render(
<Dialog title="Delete this?" onClose={vi.fn()}>
<button type="button">Inside</button>
</Dialog>,
)
const dialog = screen.getByRole('dialog')
expect(dialog).toHaveAttribute('aria-modal', 'true')
expect(dialog).toHaveAccessibleName('Delete this?')
})
})
describe('typed confirmation (§67)', () => {
it('keeps the destructive action disabled until the name is typed', async () => {
const user = userEvent.setup()
const onConfirm = vi.fn()
render(
<ConfirmDialog
title="Delete “Westhaven”?"
confirmLabel="Delete this campaign"
destructive
requireText="Westhaven"
onConfirm={onConfirm}
onCancel={vi.fn()}
>
<p>This cannot be undone.</p>
</ConfirmDialog>,
)
const confirm = screen.getByRole('button', { name: 'Delete this campaign' })
expect(confirm).toBeDisabled()
await user.type(screen.getByRole('textbox'), 'Westhaven')
expect(confirm).toBeEnabled()
await user.click(confirm)
expect(onConfirm).toHaveBeenCalled()
})
it('rejects a near miss', async () => {
const user = userEvent.setup()
render(
<ConfirmDialog
title="Delete?" requireText="Westhaven"
onConfirm={vi.fn()} onCancel={vi.fn()}
>
<p>x</p>
</ConfirmDialog>,
)
await user.type(screen.getByRole('textbox'), 'Westhaben')
expect(screen.getByRole('button', { name: 'Confirm' })).toBeDisabled()
})
it('needs no typing when no name is required', () => {
render(
<ConfirmDialog title="Restore?" onConfirm={vi.fn()} onCancel={vi.fn()}>
<p>x</p>
</ConfirmDialog>,
)
expect(screen.queryByRole('textbox')).toBeNull()
expect(screen.getByRole('button', { name: 'Confirm' })).toBeEnabled()
})
it('cancels without acting', async () => {
const user = userEvent.setup()
const onConfirm = vi.fn()
const onCancel = vi.fn()
render(
<ConfirmDialog title="Delete?" onConfirm={onConfirm} onCancel={onCancel}>
<p>x</p>
</ConfirmDialog>,
)
await user.click(screen.getByRole('button', { name: 'Cancel' }))
expect(onCancel).toHaveBeenCalled()
expect(onConfirm).not.toHaveBeenCalled()
})
it('shows what the dialog is about', () => {
render(
<ConfirmDialog title="Delete?" onConfirm={vi.fn()} onCancel={vi.fn()}>
<p>Turns that already used it are unchanged.</p>
</ConfirmDialog>,
)
const dialog = screen.getByRole('dialog')
expect(within(dialog).getByText(/already used it are unchanged/)).toBeInTheDocument()
})
})
-110
View File
@@ -1,110 +0,0 @@
import { useEffect, useRef } from 'react'
// Motes per million CSS pixels of viewport. Tuned so a laptop gets ~55 and a
// phone ~20: enough to read as drifting dust, few enough to stay cheap.
const DENSITY = 34
const MAX_PARTICLES = 90
/** Slow-drifting gold motes behind the whole app.
*
* Fixed, non-interactive, and drawn on one canvas. It sits below every page in
* the stacking order and never receives pointer events, so it cannot affect
* layout or intercept clicks. Disabled outright for `prefers-reduced-motion`,
* and paused whenever the tab is hidden so a backgrounded tab costs nothing.
*/
export default function Embers() {
const canvasRef = useRef(null)
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)')
if (reduced.matches) return
const ctx = canvas.getContext('2d')
if (!ctx) return
// Cap the backing store at 2x: beyond that the cost is real and nobody can
// see the difference on a blurred mote.
const dpr = Math.min(window.devicePixelRatio || 1, 2)
let width = 0
let height = 0
let particles = []
let frame = 0
const spawn = (scattered) => ({
x: Math.random() * width,
// New motes enter from just below the fold; the first batch is scattered
// across the whole height so the screen isn't empty on load.
y: scattered ? Math.random() * height : height + Math.random() * 40,
radius: 0.6 + Math.random() * 1.6,
speed: 0.08 + Math.random() * 0.3,
sway: Math.random() * Math.PI * 2,
swaySpeed: 0.004 + Math.random() * 0.01,
alpha: 0.12 + Math.random() * 0.4,
})
const resize = () => {
width = window.innerWidth
height = window.innerHeight
canvas.width = Math.round(width * dpr)
canvas.height = Math.round(height * dpr)
canvas.style.width = `${width}px`
canvas.style.height = `${height}px`
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
const target = Math.min(
MAX_PARTICLES,
Math.max(12, Math.round((width * height * DENSITY) / 1_000_000)),
)
particles = Array.from({ length: target }, () => spawn(true))
}
const draw = () => {
ctx.clearRect(0, 0, width, height)
for (let i = 0; i < particles.length; i++) {
const p = particles[i]
p.sway += p.swaySpeed
p.y -= p.speed
p.x += Math.sin(p.sway) * 0.28
if (p.y < -8) {
particles[i] = spawn(false)
continue
}
// Fade out toward the top of the screen so motes dissolve rather than
// vanishing at the edge.
const fade = Math.min(1, p.y / height)
const glow = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.radius * 4)
glow.addColorStop(0, `rgba(232, 196, 118, ${(p.alpha * fade).toFixed(3)})`)
glow.addColorStop(1, 'rgba(212, 169, 78, 0)')
ctx.fillStyle = glow
ctx.beginPath()
ctx.arc(p.x, p.y, p.radius * 4, 0, Math.PI * 2)
ctx.fill()
}
frame = requestAnimationFrame(draw)
}
const start = () => {
if (!frame) frame = requestAnimationFrame(draw)
}
const stop = () => {
cancelAnimationFrame(frame)
frame = 0
}
const onVisibility = () => (document.hidden ? stop() : start())
resize()
start()
window.addEventListener('resize', resize)
document.addEventListener('visibilitychange', onVisibility)
return () => {
stop()
window.removeEventListener('resize', resize)
document.removeEventListener('visibilitychange', onVisibility)
}
}, [])
return <canvas ref={canvasRef} className="embers" aria-hidden="true" />
}
+49
View File
@@ -0,0 +1,49 @@
/* Leaving the local-only environment, deliberately.
*
* `BROWSER-UX-SPEC.md` §74. A URL can arrive in the story from two places the
* reader did not write: the narrator can invent one, and an imported file can
* carry one. Neither is a reason to distrust the reader's judgement, but both
* are a reason not to navigate silently — the whole product runs on this
* machine, and following a link is the one ordinary action that leaves it.
*
* The address is shown in full, as text, before anything is opened. That is the
* point: a link whose text says one thing and whose href says another is the
* oldest trick there is, and the only defence that works is showing the reader
* where they are actually going.
*
* Opening uses `noopener`, so the destination gets no handle on this window.
*/
import { Dialog } from './Dialog'
export function ExternalLinkDialog({ href, onClose }) {
let shown = href
try {
shown = new URL(href, window.location.origin).toString()
} catch { /* show the raw text if it will not parse */ }
return (
<Dialog title="This link leaves the storyteller" onClose={onClose}>
<div className="dialog-body">
<p>
Everything else in this application stays on your machine. Opening this
link goes out to the internet.
</p>
<p className="external-href"><code>{shown}</code></p>
</div>
<div className="dialog-actions">
<button type="button" onClick={onClose}>Stay here</button>
<button
type="button"
className="primary"
onClick={() => {
window.open(shown, '_blank', 'noopener,noreferrer')
onClose()
}}
>
Open in a new tab
</button>
</div>
</Dialog>
)
}
+131
View File
@@ -0,0 +1,131 @@
/* The way out of a blank model configuration.
*
* This is M8 §8's carried debt, and the debt was not that the error was ugly —
* it was that the reader had nothing to *do*. `Settings.model` could be empty,
* nothing said so, and play failed on the first turn with a provider message.
*
* So this component is a fix rather than a warning. When the endpoint is
* reachable it lists the models actually installed there, from the connection
* test the settings screen already had, and choosing one writes it back and
* re-tests. Nothing is chosen automatically: picking the first model in the
* listing would silently narrate a campaign with whatever happened to sort
* first — possibly an embedding model, which cannot narrate at all — and the
* milestone brief rules that out in the absence of a product rule authorizing it.
*
* When the endpoint is *not* reachable there is no list to offer, so it gives
* the local troubleshooting §44 asks for instead.
*/
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useModelStatus } from './modelStatus'
export function ModelSetupNotice({ compact = false }) {
const { status, models, model, endpoint, detail, chooseModel, refresh } = useModelStatus()
const [busy, setBusy] = useState(false)
const [failed, setFailed] = useState(null)
const pick = async (name) => {
setBusy(true)
setFailed(null)
try {
await chooseModel(name)
} catch (err) {
setFailed(err.message)
} finally {
setBusy(false)
}
}
if (status === 'ready' || status === 'checking') return null
return (
<div className="notice model-setup" role="status" data-testid="model-setup-notice">
{status === 'unavailable' && (
<>
<strong>Ollama is not reachable</strong>
<p>
The storyteller narrates with a model running on your own machine or
your own network. Nothing here works until it can reach one.
</p>
{detail && <p className="notice-detail">{detail}</p>}
<ol className="notice-steps">
<li>Install Ollama, if it is not installed.</li>
<li>Start it: <code>ollama serve</code></li>
<li>
Pull a model to narrate with, for example{' '}
<code>ollama pull qwen2.5:3b-instruct</code>
</li>
<li>
Check the endpoint in Settings. It is currently{' '}
<code>{endpoint || '(not set)'}</code>.
</li>
</ol>
</>
)}
{status === 'no-model' && (
<>
<strong>Choose a narrator model</strong>
<p>
Ollama is running, but no model has been chosen to tell the story.
These are installed on it:
</p>
{models.length > 0 ? (
<ul className="model-choices">
{models.map((name) => (
<li key={name}>
<button type="button" disabled={busy} onClick={() => pick(name)}>
{name}
</button>
</li>
))}
</ul>
) : (
<p>
It has no models installed. Pull one first, for example{' '}
<code>ollama pull qwen2.5:3b-instruct</code>, then{' '}
<button type="button" className="linklike" onClick={refresh}>check again</button>.
</p>
)}
{/* An embedding model in the list is a real trap — it narrates
nothing and fails obscurely — so say so rather than filtering the
list on a name pattern that would be wrong for some model. */}
{models.some((m) => m.includes('embed')) && (
<p className="field-hint">
A model with “embed” in its name is for searching your imported
material, not for narrating.
</p>
)}
</>
)}
{status === 'missing-model' && (
<>
<strong>The chosen model is not installed</strong>
<p>
This campaign is set to narrate with <code>{model}</code>, which the
endpoint does not have. Pull it there with{' '}
<code>ollama pull {model}</code>, or choose one it does have:
</p>
<ul className="model-choices">
{models.map((name) => (
<li key={name}>
<button type="button" disabled={busy} onClick={() => pick(name)}>
{name}
</button>
</li>
))}
</ul>
</>
)}
{failed && <p className="notice-detail error">{failed}</p>}
{!compact && (
<p className="notice-foot">
<Link to="/settings">Open Settings</Link>
</p>
)}
</div>
)
}
+39
View File
@@ -0,0 +1,39 @@
/* "Ollama: Connected — qwen2.5:3b-instruct", or the reason it is not.
*
* `BROWSER-UX-SPEC.md` §44. It is a status line, not a control: the only thing
* it does is link to the place the problem is fixed. Rendered as a `<Link>`
* rather than a button because it navigates, which is what a screen reader
* should be told it does.
*/
import { Link } from 'react-router-dom'
import { blocksPlay, statusLabel, useModelStatus } from './modelStatus'
export function ModelStatusBadge({ compact = false }) {
const { status, model, endpoint } = useModelStatus()
const bad = blocksPlay(status)
const label = statusLabel(status)
return (
<Link
to="/settings"
className={`model-badge ${bad ? 'bad' : ''} ${status === 'checking' ? 'checking' : ''}`}
data-testid="model-status"
data-status={status}
// The visible text is two short fragments; the accessible name is the
// whole sentence, including the endpoint, which sighted readers get from
// Settings rather than from a badge in a nav bar.
aria-label={
status === 'ready'
? `${label}. Narrator model ${model}. Endpoint ${endpoint}. Open Settings.`
: `${label}. Open Settings.`
}
>
<span className="model-badge-dot" aria-hidden="true" />
<span className="model-badge-text">{label}</span>
{!compact && status === 'ready' && model && (
<span className="model-badge-model">{model}</span>
)}
</Link>
)
}
-332
View File
@@ -1,332 +0,0 @@
import { useEffect, useState } from 'react'
import { npcInitials } from './components'
// Rebuild an object with one key renamed, preserving order. Returns null on a
// no-op or a collision (so the caller keeps the old object).
function withRenamedKey(obj, oldKey, newKey) {
if (!newKey || oldKey === newKey || obj[newKey] !== undefined) return null
const next = {}
for (const [k, v] of Object.entries(obj)) next[k === oldKey ? newKey : k] = v
return next
}
// A key/id input that commits on blur (renaming a key mid-keystroke would
// rebuild the parent object and steal focus).
function KeyInput({ value, onCommit, placeholder }) {
const [v, setV] = useState(value)
useEffect(() => { setV(value) }, [value])
const commit = () => {
const trimmed = v.trim()
if (trimmed && trimmed !== value) onCommit(trimmed)
else setV(value)
}
return (
<input className="se-key" value={v} placeholder={placeholder}
onChange={(e) => setV(e.target.value)}
onBlur={commit}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }} />
)
}
// Every control in this editor wears the same tiny caption — that consistency
// is what keeps the mixed row types (stats, flags, milestones, NPCs) reading
// as one form rather than five.
function Field({ label, className = '', children }) {
return (
<label className={`se-field ${className}`}>
<span>{label}</span>
{children}
</label>
)
}
function Num({ label, value, onChange }) {
return (
<Field label={label} className="se-num">
<input type="number" value={value ?? ''}
onChange={(e) => onChange(e.target.value === '' ? undefined : Number(e.target.value))} />
</Field>
)
}
function StatEditor({ statKey, def, onRenameKey, onChange, onRemove }) {
const set = (field, val) => {
const next = { ...def }
if (val === undefined || val === '') delete next[field]
else next[field] = val
onChange(next)
}
const isText = def.type === 'text'
const bands = Array.isArray(def.bands) ? def.bands : []
const setBands = (nb) => set('bands', nb.length ? nb : undefined)
const updBand = (i, j, raw) => {
const nb = bands.map((b) => (Array.isArray(b) ? [...b] : [0, 0, '']))
while (nb[i].length < 3) nb[i].push(j < 2 ? 0 : '')
nb[i][j] = j < 2 ? (raw === '' ? 0 : Number(raw)) : raw
setBands(nb)
}
const setType = (val) => {
const next = { ...def, type: val || undefined }
if (val === 'text') {
// Numeric-only fields don't apply to free text.
delete next.min; delete next.max; delete next.max_delta_per_turn; delete next.bands
if (typeof next.initial !== 'string') next.initial = ''
} else if (typeof next.initial === 'string') {
delete next.initial
}
onChange(next)
}
return (
<div className="se-row">
<div className="se-row-top">
<KeyInput value={statKey} onCommit={onRenameKey} placeholder="stat_name" />
<button type="button" className="se-remove" onClick={onRemove} title="Remove stat">✕</button>
</div>
<div className="se-fields">
{isText ? (
<Field label="initial" className="se-num se-initial-text">
<input type="text" value={def.initial ?? ''}
onChange={(e) => set('initial', e.target.value)} />
</Field>
) : (
<>
<Num label="min" value={def.min} onChange={(v) => set('min', v)} />
<Num label="max" value={def.max} onChange={(v) => set('max', v)} />
<Num label="initial" value={def.initial} onChange={(v) => set('initial', v)} />
<Num label="±/turn" value={def.max_delta_per_turn} onChange={(v) => set('max_delta_per_turn', v)} />
</>
)}
<Num label="cooldown" value={def.cooldown} onChange={(v) => set('cooldown', v)} />
<Field label="kind" className="se-num se-kind">
<select value={isText ? 'text' : (def.type === 'counter' ? 'counter' : 'number')}
onChange={(e) => setType(e.target.value === 'number' ? undefined : e.target.value)}>
<option value="number">number</option>
<option value="counter">counts up only</option>
<option value="text">free text</option>
</select>
</Field>
</div>
<Field label="description (shown to the AI)">
<input className="se-text" value={def.desc || ''} placeholder="e.g. how badly wounded you are"
onChange={(e) => set('desc', e.target.value || undefined)} />
</Field>
{!isText && (
<div className="se-bands">
<div className="se-sub-head">Bands — low, high, label</div>
{bands.map((b, i) => (
<div key={i} className="se-band">
<input type="number" className="se-band-n" value={b?.[0] ?? ''}
onChange={(e) => updBand(i, 0, e.target.value)} />
<input type="number" className="se-band-n" value={b?.[1] ?? ''}
onChange={(e) => updBand(i, 1, e.target.value)} />
<input className="se-band-l" value={b?.[2] ?? ''} placeholder="label"
onChange={(e) => updBand(i, 2, e.target.value)} />
<button type="button" className="se-remove"
onClick={() => setBands(bands.filter((_, k) => k !== i))} title="Remove band">✕</button>
</div>
))}
<button type="button" className="se-add-sm"
onClick={() => setBands([...bands, [0, 0, '']])}>+ band</button>
</div>
)}
</div>
)
}
function StatSection({ title, hint, defs, onChange, addLabel = '+ stat', nested }) {
const entries = Object.entries(defs || {})
const rename = (o, n) => { const x = withRenamedKey(defs, o, n); if (x) onChange(x) }
const setDef = (k, val) => onChange({ ...defs, [k]: val })
const remove = (k) => { const x = { ...defs }; delete x[k]; onChange(x) }
const add = () => {
let i = 1, key = 'stat'
while (defs[key]) key = `stat${i++}`
onChange({ ...defs, [key]: { min: 0, max: 100, initial: 0 } })
}
return (
<div className={nested ? 'se-section se-section-nested' : 'se-section'}>
<div className="se-section-head">
<span className="se-section-title">{title}</span>
<button type="button" className="se-add" onClick={add}>{addLabel}</button>
</div>
{hint && <p className="se-hint">{hint}</p>}
{entries.length === 0 && <div className="se-empty">None yet.</div>}
{entries.map(([k, d]) => (
<StatEditor key={k} statKey={k} def={d || {}}
onRenameKey={(nk) => rename(k, nk)}
onChange={(nd) => setDef(k, nd)}
onRemove={() => remove(k)} />
))}
</div>
)
}
function FlagSection({ flags, onChange }) {
const entries = Object.entries(flags || {})
const rename = (o, n) => { const x = withRenamedKey(flags, o, n); if (x) onChange(x) }
const setFlag = (k, val) => onChange({ ...flags, [k]: val })
const remove = (k) => { const x = { ...flags }; delete x[k]; onChange(x) }
const add = () => {
let i = 1, key = 'flag'
while (flags[key]) key = `flag${i++}`
onChange({ ...flags, [key]: { initial: false, desc: '' } })
}
return (
<div className="se-section">
<div className="se-section-head">
<span className="se-section-title">Flags</span>
<button type="button" className="se-add" onClick={add}>+ flag</button>
</div>
<p className="se-hint">On/off switches the AI can flip either way — a door unlocked, an alarm raised.</p>
{entries.length === 0 && <div className="se-empty">No flags.</div>}
{entries.map(([k, f]) => (
<div key={k} className="se-row">
<div className="se-row-top">
<KeyInput value={k} onCommit={(nk) => rename(k, nk)} placeholder="flag_name" />
<label className="se-check">
<input type="checkbox" checked={!!f.initial}
onChange={(e) => setFlag(k, { ...f, initial: e.target.checked })} />
<span>on by default</span>
</label>
<button type="button" className="se-remove" onClick={() => remove(k)} title="Remove flag">✕</button>
</div>
<Field label="description (shown to the AI)">
<input className="se-text" value={f.desc || ''} placeholder="e.g. the cellar door is unlocked"
onChange={(e) => setFlag(k, { ...f, desc: e.target.value })} />
</Field>
</div>
))}
</div>
)
}
function MilestoneSection({ milestones, onChange }) {
const entries = Object.entries(milestones || {})
const rename = (o, n) => { const x = withRenamedKey(milestones, o, n); if (x) onChange(x) }
const setM = (k, val) => onChange({ ...milestones, [k]: val })
const remove = (k) => { const x = { ...milestones }; delete x[k]; onChange(x) }
const add = () => {
let i = 1, key = 'goal'
while (milestones[key]) key = `goal${i++}`
onChange({ ...milestones, [key]: { desc: '' } })
}
return (
<div className="se-section">
<div className="se-section-head">
<span className="se-section-title">Milestones</span>
<button type="button" className="se-add" onClick={add}>+ milestone</button>
</div>
<p className="se-hint">Objectives that stick once reached — they never un-tick on their own.</p>
{entries.length === 0 && <div className="se-empty">No milestones.</div>}
{entries.map(([k, m]) => (
<div key={k} className="se-row">
<div className="se-row-top">
<KeyInput value={k} onCommit={(nk) => rename(k, nk)} placeholder="milestone_id" />
<button type="button" className="se-remove" onClick={() => remove(k)} title="Remove milestone">✕</button>
</div>
<Field label="objective">
<input className="se-text" value={m?.desc || ''} placeholder="e.g. escaped the bandit camp"
onChange={(e) => setM(k, { ...m, desc: e.target.value })} />
</Field>
</div>
))}
</div>
)
}
// ---- NPCs ----------------------------------------------------------------
// NPCs live inside the same stat_schema (`schema.npcs`) but get their own
// top-level section in the editor: each one is a small character sheet, which
// doesn't fit the flat stat rows the other sections use.
// Next `npcs` object with a fresh entry appended, and the id it used.
export function addNpc(npcs) {
let i = 1, key = 'npc'
while (npcs?.[key]) key = `npc${i++}`
return { ...(npcs || {}), [key]: { name: '', keys: '', desc: '', stats: {} } }
}
function NpcCard({ npcId, npc, onRenameKey, onChange, onRemove }) {
const set = (field, val) => onChange({ ...npc, [field]: val })
const statCount = Object.keys(npc.stats || {}).length
return (
<div className="se-npc">
<div className="se-npc-head">
<span className="se-avatar" aria-hidden="true">{npcInitials(npc.name, npcId)}</span>
<div className="se-npc-ident">
<input className="se-npc-name" value={npc.name || ''} placeholder="Display name"
onChange={(e) => set('name', e.target.value)} />
<code className="se-npc-addr">npc.{npcId}</code>
</div>
<span className="se-npc-count">{statCount} {statCount === 1 ? 'stat' : 'stats'}</span>
<button type="button" className="se-remove" onClick={onRemove} title="Remove NPC">✕</button>
</div>
<div className="se-npc-body">
<Field label="id (how the AI addresses them)" className="se-npc-idfield">
<KeyInput value={npcId} onCommit={onRenameKey} placeholder="npc_id" />
</Field>
<Field label="trigger words, comma-separated">
<input className="se-text" value={npc.keys || ''} placeholder="Gwen, ranger, the scout"
onChange={(e) => set('keys', e.target.value)} />
</Field>
<Field label="description (lore + shown to the AI)">
<textarea className="se-text se-area" rows={2} value={npc.desc || ''}
placeholder="A wary ranger who owes you a debt."
onChange={(e) => set('desc', e.target.value)} />
</Field>
<StatSection title="Their stats" defs={npc.stats || {}} nested
onChange={(nd) => set('stats', nd)} />
</div>
</div>
)
}
// The NPC roster. Takes/returns just the `npcs` slice of a stat_schema.
export function NpcEditor({ npcs, onChange }) {
const entries = Object.entries(npcs || {})
const rename = (o, n) => { const x = withRenamedKey(npcs, o, n); if (x) onChange(x) }
const setNpc = (k, val) => onChange({ ...npcs, [k]: val })
const remove = (k) => { const x = { ...npcs }; delete x[k]; onChange(x) }
if (entries.length === 0) {
return (
<div className="empty" style={{ padding: '20px 0' }}>
No NPCs yet. Each one gets its own stats (trust, health, ferocity — whatever suits them),
and a story card is created automatically so they show up in context when mentioned.
</div>
)
}
return (
<div className="se se-npc-grid">
{entries.map(([k, npc]) => (
<NpcCard key={k} npcId={k} npc={npc || {}}
onRenameKey={(nk) => rename(k, nk)}
onChange={(n) => setNpc(k, n)}
onRemove={() => remove(k)} />
))}
</div>
)
}
// Form-based editor for a stat_schema, minus the NPCs (see `NpcEditor`, which
// the scenario editor renders as its own page section). `schema` is the parsed
// object (or null); `onChange(nextSchema)` fires on every edit. Empty sections
// are dropped.
export default function SchemaEditor({ schema, onChange }) {
const s = schema && typeof schema === 'object' ? schema : {}
const setSection = (key, val) => {
const next = { ...s }
if (val && Object.keys(val).length) next[key] = val
else delete next[key]
onChange(next)
}
return (
<div className="se">
<StatSection title="World stats" defs={s.world || {}} onChange={(v) => setSection('world', v)}
hint="Things about the situation, not any one character — time of day, the camp’s alert level." />
<StatSection title="Player stats" defs={s.player || {}} onChange={(v) => setSection('player', v)}
hint="The player’s own numbers — hp, gold, reputation — plus free-text ones like an outfit." />
<FlagSection flags={s.flags || {}} onChange={(v) => setSection('flags', v)} />
<MilestoneSection milestones={s.milestones || {}} onChange={(v) => setSection('milestones', v)} />
</div>
)
}
+166
View File
@@ -0,0 +1,166 @@
/* Accessibility, as behaviour rather than as a claim.
*
* §28 asks for semantic HTML, keyboard navigation, visible focus, descriptive
* labels, sensible transcript structure and dialogs that manage focus. The
* parts of that a test can actually decide are here; contrast and visible focus
* are CSS and were checked by eye in the browser (recorded in the M8 report).
*
* The baseline this replaces: sixteen of sixteen per-message controls on a
* two-turn story had no accessible name — only a `title` on a single glyph.
*/
import { screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from './api'
import App from './App'
import Campaigns from './pages/Campaigns'
import NewCampaign from './pages/NewCampaign'
import { Composer } from './pages/Play/Composer'
import { mockModelStatus, renderWith } from './test/helpers'
beforeEach(() => {
vi.restoreAllMocks()
mockModelStatus(api)
})
/** Every control in `container` has a non-trivial accessible name. */
function everyControlIsNamed(container) {
const unnamed = []
container.querySelectorAll('button, a[href], select, textarea, input').forEach((el) => {
if (el.type === 'hidden') return
const label = el.getAttribute('aria-label')
|| el.getAttribute('aria-labelledby')
|| (el.labels && el.labels.length ? el.labels[0].textContent : '')
|| el.textContent
|| ''
if (label.trim().length < 3) unnamed.push(el.outerHTML.slice(0, 90))
})
return unnamed
}
describe('controls have names, not glyphs', () => {
it('in the composer', async () => {
const { container } = await renderWith(
<Composer
input="" direction={false} busy={false}
canUndo canRedo canRetry
setInput={vi.fn()} setDirection={vi.fn()} onSend={vi.fn()} onContinue={vi.fn()}
onRetry={vi.fn()} onUndo={vi.fn()} onRedo={vi.fn()} onSavePoint={vi.fn()}
onStop={vi.fn()}
/>,
)
expect(everyControlIsNamed(container)).toEqual([])
})
it('in the campaign library', async () => {
vi.spyOn(api, 'listAdventures').mockResolvedValue([
{ id: 1, title: 'Continuity Test', action_count: 4, updated_at: '2026-09-06T10:00:00', snippet: 'x' },
])
const { container } = await renderWith(<Campaigns />)
expect(everyControlIsNamed(container)).toEqual([])
})
it('in the setup form', async () => {
const { container } = await renderWith(<NewCampaign />)
expect(everyControlIsNamed(container)).toEqual([])
})
})
describe('form fields are labelled', () => {
it('every input in setup has a label element bound to it', async () => {
const { container } = await renderWith(<NewCampaign />)
const fields = container.querySelectorAll('input:not([type=radio]):not([type=checkbox]), textarea, select')
expect(fields.length).toBeGreaterThan(5)
for (const field of fields) {
expect(field.labels?.length, `${field.id || field.outerHTML.slice(0, 60)} has no label`)
.toBeGreaterThan(0)
}
})
})
describe('semantic structure', () => {
it('the library uses a list and one h1', async () => {
vi.spyOn(api, 'listAdventures').mockResolvedValue([
{ id: 1, title: 'A', action_count: 1, updated_at: '2026-09-06T10:00:00' },
{ id: 2, title: 'B', action_count: 2, updated_at: '2026-09-06T10:00:00' },
])
const { container } = await renderWith(<Campaigns />)
expect(container.querySelectorAll('h1')).toHaveLength(1)
expect(container.querySelectorAll('ul.campaign-grid > li')).toHaveLength(2)
})
it('a campaign is opened by a link, not a click handler on a div', async () => {
vi.spyOn(api, 'listAdventures').mockResolvedValue([
{ id: 7, title: 'Continuity Test', action_count: 1, updated_at: '2026-09-06T10:00:00' },
])
await renderWith(<Campaigns />)
// Two routes to the same place, both real links, both keyboard-reachable.
const links = screen.getAllByRole('link', { name: /Continuity Test|Open/ })
expect(links.length).toBeGreaterThan(0)
for (const link of links) expect(link).toHaveAttribute('href', '/play/7')
})
it('the setup form is a form, so Enter submits it', async () => {
const { container } = await renderWith(<NewCampaign />)
expect(container.querySelector('form')).toBeInTheDocument()
expect(container.querySelector('button[type=submit]')).toBeInTheDocument()
})
})
describe('the shell', () => {
it('offers a skip link and one main landmark', async () => {
vi.spyOn(api, 'listAdventures').mockResolvedValue([])
const { container } = await renderWith(<App />)
expect(screen.getByText('Skip to main content')).toHaveAttribute('href', '#main')
expect(container.querySelectorAll('main')).toHaveLength(1)
expect(container.querySelector('main')).toHaveAttribute('id', 'main')
})
it('names its navigation', async () => {
vi.spyOn(api, 'listAdventures').mockResolvedValue([])
await renderWith(<App />)
expect(screen.getByRole('navigation', { name: 'Main' })).toBeInTheDocument()
})
})
describe('keyboard', () => {
it('reaches every story control by Tab, in order', async () => {
const user = userEvent.setup()
await renderWith(
<Composer
input="" direction={false} busy={false}
canUndo canRedo canRetry
setInput={vi.fn()} setDirection={vi.fn()} onSend={vi.fn()} onContinue={vi.fn()}
onRetry={vi.fn()} onUndo={vi.fn()} onRedo={vi.fn()} onSavePoint={vi.fn()}
onStop={vi.fn()}
/>,
)
const order = ['Continue', 'Retry', 'Undo', 'Redo', 'Save Point']
for (const label of order) {
await user.tab()
expect(document.activeElement).toHaveAccessibleName(label)
}
// Then the direction toggle, then the box itself.
await user.tab()
expect(document.activeElement.type).toBe('checkbox')
await user.tab()
expect(document.activeElement.tagName).toBe('TEXTAREA')
})
it('does not put the disabled dictation control in the tab order', async () => {
const user = userEvent.setup()
await renderWith(
<Composer
input="" direction={false} busy={false}
canUndo={false} canRedo={false} canRetry={false}
setInput={vi.fn()} setDirection={vi.fn()} onSend={vi.fn()} onContinue={vi.fn()}
onRetry={vi.fn()} onUndo={vi.fn()} onRedo={vi.fn()} onSavePoint={vi.fn()}
onStop={vi.fn()}
/>,
)
for (let i = 0; i < 12; i++) await user.tab()
// A disabled button is skipped by the browser; assert it never took focus.
expect(screen.getByTestId('dictate-reserved')).not.toHaveFocus()
})
})
+8
View File
@@ -153,6 +153,14 @@ export const api = {
retry: (advId, handlers, signal) => streamSSE(`/adventures/${advId}/retry`, {}, handlers, signal),
exportAdventure: (id) => request(`/adventures/${id}/export`),
importAdventure: (bundle) => request('/adventures/import', { method: 'POST', body: JSON.stringify(bundle) }),
// M9. A verified copy of the whole database, which is a different tool from
// exporting one campaign: the export moves a campaign between installations,
// and this is a safety copy of everything on this machine. Neither takes a
// path — the server derives the destination from the database it already has
// open, so there is nothing here for a caller to point somewhere else.
listBackups: () => request('/backups'),
createBackup: () => request('/backups', { method: 'POST' }),
undo: (advId) => request(`/adventures/${advId}/undo`, { method: 'POST' }),
// Undo moves the story back without deleting it, so there is somewhere to
// move forward to again (M3). Both answer with the newest window.
-151
View File
@@ -1,151 +0,0 @@
// The story tree, as numbers something can be drawn from.
//
// `GET /adventures/{id}/branches` answers two numbers per branch — `fork_depth`,
// where a line leaves its parent, and `depth`, where it currently ends — which
// is deliberately enough to draw the whole shape without walking a single node.
// Both readers of that shape live behind this file: the list in the Branches
// panel and the map overlay. They order and label a branch the same way because
// they order and label it *here*, so a branch cannot appear in one and not the
// other, or be called two different things by the two of them.
// Derived, never stored: a generated name in the column would go stale the
// moment a branch before it is deleted. A fork depth is a coordinate, so it
// says the same thing whatever else is thrown away.
export function branchLabel(branch) {
if (branch.name) return branch.name
if (branch.parent_branch_id === null) return 'The first telling'
return `Fork at moment ${branch.fork_depth + 1}`
}
// Parents before children, each child under the branch it left.
export function orderBranches(branches) {
const kids = new Map()
for (const b of branches) {
const key = b.parent_branch_id
if (!kids.has(key)) kids.set(key, [])
kids.get(key).push(b)
}
const out = []
const walk = (parentId, indent) => {
for (const b of kids.get(parentId) || []) {
out.push({ branch: b, indent })
walk(b.id, indent + 1)
}
}
// Anything whose parent is missing would otherwise never be walked. That
// cannot happen through the API, but a list that silently drops a branch is
// the one bug this panel exists to make impossible to have.
walk(null, 0)
const seen = new Set(out.map((row) => row.branch.id))
for (const b of branches) if (!seen.has(b.id)) out.push({ branch: b, indent: 0 })
return out
}
// The branches the head is standing on: itself, and everything it borrows from.
//
// This is the client's copy of the server's delete rule — `parent_branch_id`
// cascades, so deleting an ancestor of the head takes the head with it and
// leaves `head_branch_id` pointing at a row that is gone. The server refuses
// exactly this set; computing it here only means the button can say so before
// it is pressed. The server stays the authority.
export function headLineage(branches) {
const byId = new Map(branches.map((b) => [b.id, b]))
const out = new Set()
let cur = branches.find((b) => b.is_head)
// The guard is against a cycle, which the schema forbids and a walk should
// still never hang on.
while (cur && !out.has(cur.id)) {
out.add(cur.id)
cur = cur.parent_branch_id === null ? null : byId.get(cur.parent_branch_id)
}
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
export const PAD = { top: 44, right: 26, bottom: 20, left: 24 }
export const CORNER = 11 // radius of the elbow a fork turns through
// Place every branch on its own horizontal lane, in tree order.
//
// A lane runs from where its branch left its parent to where its branch ends,
// so the horizontal axis is the story's own clock: two branches at the same x
// are at the same moment, and the length of a lane is how much of the story it
// covers. Nothing here is measured in pixels — the component owns the mapping
// from a moment to an x, because only it knows how wide it ended up.
export function layoutTree(branches) {
const rows = orderBranches(branches)
const rowOf = new Map(rows.map((row, i) => [row.branch.id, i]))
const lanes = rows.map(({ branch }, row) => ({
branch,
row,
// The first telling starts where the story does; every other line starts
// where it walked away from another one.
from: branch.parent_branch_id === null ? 0 : (branch.fork_depth ?? 0),
to: branch.depth,
parentRow: branch.parent_branch_id === null
? null
: (rowOf.has(branch.parent_branch_id) ? rowOf.get(branch.parent_branch_id) : null),
}))
return {
lanes,
// The whole map is scaled to the longest path, so a short branch reads as
// short. `|| 1` keeps a one-moment story from dividing by zero.
maxDepth: Math.max(0, ...branches.map((b) => b.depth)),
height: PAD.top + rows.length * ROW_H + PAD.bottom,
}
}
// Round tick marks for the moment axis: about `count` of them, landing on
// numbers a person would have chosen (1, 2, 5, 10, 25, 50 …) rather than on
// whatever `maxDepth / 6` happens to be.
export function momentTicks(maxDepth, count = 6) {
if (maxDepth <= 0) return [0]
const raw = maxDepth / count
const magnitude = 10 ** Math.floor(Math.log10(raw))
const step = [1, 2, 2.5, 5, 10].map((m) => m * magnitude).find((s) => s >= raw) || magnitude * 10
const out = []
for (let d = 0; d <= maxDepth; d += step) out.push(Math.round(d))
// The last moment is worth naming, but not on top of the tick before it —
// at a narrow width `26` and `27` printed as `2627`.
const last = out[out.length - 1]
if (maxDepth - last > step * 0.6) out.push(maxDepth)
else if (last !== maxDepth) out[out.length - 1] = maxDepth
return out
}
+40 -61
View File
@@ -1,5 +1,4 @@
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
import { api } from './api'
export function downloadJSON(obj, filename) {
const blob = new Blob([JSON.stringify(obj, null, 2)], { type: 'application/json' })
@@ -16,25 +15,59 @@ export function pickJSONFile() {
const input = document.createElement('input')
input.type = 'file'
input.accept = '.json,application/json'
// M9: in the document, and driveable, rather than detached.
//
// A detached input is what this was, and `.click()` on one opens the
// browser's file dialog in Firefox and Chrome today — but it is not
// something the HTML spec requires, and it made the one control that
// recovers a campaign impossible to drive from a browser test: there is no
// element for WebDriver to hand a path to, so the import workflow could
// only ever be checked by calling the API underneath it.
//
// Taken out of layout rather than marked `hidden`, and the difference is
// load-bearing. A `hidden` input is non-interactable, and WebDriver will
// set `files` on one without dispatching `change` — so the file lands and
// nothing happens, which is a worse failure than the detached input was
// because it looks like it worked. This is the ordinary visually-hidden
// file-input pattern: off-screen, zero-sized, out of the accessibility
// tree and out of the tab order, so no reader meets a stray "Choose file"
// control while the browser's own dialog is what they are looking at.
input.setAttribute('aria-hidden', 'true')
input.tabIndex = -1
input.style.cssText =
'position:fixed;left:-9999px;width:1px;height:1px;opacity:0;pointer-events:none'
input.dataset.testid = 'import-file'
const done = (settle) => (value) => { input.remove(); settle(value) }
const ok = done(resolve)
const bad = done(reject)
// M9: a cancelled dialog settles the promise.
//
// It did not before. `onchange` does not fire when the reader closes the
// picker without choosing anything, so the promise stayed pending forever
// — and the Campaigns screen awaits it, so its `finally` never ran and the
// Import button sat disabled reading "Importing…" until the page was
// reloaded. The rejection carries an empty message, because that screen
// already treats a message-less error as "they changed their mind" and
// says nothing: a cancelled dialog is not a failure to report.
input.oncancel = () => bad(Object.assign(new Error(), { message: '' }))
input.onchange = () => {
const file = input.files[0]
if (!file) return reject(new Error('No file selected'))
if (!file) return bad(new Error('No file selected'))
const reader = new FileReader()
reader.onload = () => {
try { resolve(JSON.parse(reader.result)) }
catch { reject(new Error('Not valid JSON')) }
try { ok(JSON.parse(reader.result)) }
catch { bad(new Error('Not valid JSON')) }
}
reader.onerror = () => reject(new Error('Could not read file'))
reader.onerror = () => bad(new Error('Could not read file'))
reader.readAsText(file)
}
document.body.appendChild(input)
input.click()
})
}
// ---------- Scenario art ----------
// FNV-1a. Any stable hash works; the point is that a given title always maps to
// the same plate, so the library looks the same on every visit and every device.
function hashString(str) {
let hash = 2166136261
for (let i = 0; i < str.length; i++) {
@@ -44,9 +77,6 @@ function hashString(str) {
return hash >>> 0
}
// Deep jewel ramps that sit under gold without competing with it — the accent
// stays the brightest thing on the card. Ordered so adjacent library entries
// rarely land on neighbouring hues.
const ART_RAMPS = [
['#14424a', '#0b2328'], // drowned teal
['#4d2130', '#250f19'], // wine
@@ -58,7 +88,6 @@ const ART_RAMPS = [
['#4a3a16', '#231b09'], // ochre
]
// Up to two letters from the title's most significant words.
function monogram(title) {
const words = (title || '')
.replace(/^\[[^\]]*\]\s*/, '') // drop a leading "[Demo]" style label
@@ -68,11 +97,6 @@ function monogram(title) {
return letters.join('').toUpperCase()
}
/** Initials for an NPC's avatar disc — "Bandit Leader" → BL, "gwen" → GW.
*
* Deliberately not `monogram`: that one drops short words, which is right for
* scenario titles and wrong for names, and it has no id to fall back on.
*/
export function npcInitials(name, id) {
const src = String(name || id || '?').trim() || '?'
const words = src.split(/[\s_-]+/).filter(Boolean)
@@ -80,12 +104,6 @@ export function npcInitials(name, id) {
return letters.toUpperCase()
}
/** A scenario's plate: uploaded picture, else emoji, else generated art.
*
* `large` is for the Continue cards, where the plate carries more weight. The
* generated tier means no card is ever an empty box, so a fresh library still
* reads as a shelf of distinct things.
*/
export function ScenarioArt({ image, icon, title, large = false }) {
const [failed, setFailed] = useState(false)
const ramp = ART_RAMPS[hashString(title || '') % ART_RAMPS.length]
@@ -187,7 +205,6 @@ export function ToastHost({ children }) {
)
}
// Unique ${Placeholder} names, in order of first appearance, across the given texts.
export function extractPlaceholders(...texts) {
const names = []
for (const text of texts) {
@@ -199,18 +216,6 @@ export function extractPlaceholders(...texts) {
return names
}
// The modal shown before an adventure begins. It always asks who the player is
// playing as, and it also collects any `${Placeholder}` answers the scenario's
// text asks for.
//
// The two sets of fields are independent on purpose. A scenario that writes
// `${Name}` is asking its own question, and the persona does not answer it. No
// scenario in the repo uses placeholders at all, so the overlap is hypothetical;
// pre-filling one from the other is a small change here if it ever bites.
//
// `onSubmit` receives `{ persona, placeholders }`. Every persona field is
// optional — submitting them all blank gives an adventure with no persona,
// which behaves exactly as adventures did before personas existed.
export function BeginAdventureModal({ title, names = [], onSubmit, onCancel }) {
const [persona, setPersona] = useState({ name: '', pronouns: '', desc: '' })
const [values, setValues] = useState(Object.fromEntries(names.map((n) => [n, ''])))
@@ -302,9 +307,6 @@ export function AutoTextarea({ value, ...props }) {
return <textarea ref={ref} value={value} {...props} />
}
// `maxLength` mirrors the column width the server enforces. Without it an
// over-long value is only rejected at save time, as a 422 the player sees as a
// toast after the text is already typed.
export function Field({ label, value, onChange, textarea, rows, placeholder, maxLength }) {
return (
<label className="field">
@@ -320,26 +322,3 @@ export function Field({ label, value, onChange, textarea, rows, placeholder, max
)
}
export function StoryCardRow({ card, onChange, onDelete }) {
return (
<div className="storycard">
<div className="row">
<input type="text" placeholder="Name (not sent to AI)" value={card.name}
onChange={(e) => onChange({ ...card, name: e.target.value })} />
<input type="text" placeholder="Type (e.g. Character)" value={card.type}
onChange={(e) => onChange({ ...card, type: e.target.value })} />
</div>
<div className="row">
<input type="text" placeholder="Triggers, comma-separated" value={card.keys}
onChange={(e) => onChange({ ...card, keys: e.target.value })} />
</div>
<textarea rows={2} placeholder="Entry — sent to the AI when a trigger matches" value={card.entry}
onChange={(e) => onChange({ ...card, entry: e.target.value })} />
<div style={{ textAlign: 'right', marginTop: 6 }}>
<button className="danger" style={{ padding: '3px 10px', fontSize: '0.78rem' }} onClick={onDelete}>
Remove
</button>
</div>
</div>
)
}
+195
View File
@@ -0,0 +1,195 @@
/* Turning a failure into something a reader can act on.
*
* `BROWSER-UX-SPEC.md` §71 asks for five kinds of failure to be told apart, and
* forbids collapsing them into "Something went wrong". They are told apart
* because each one has a different thing to *do* about it: start Ollama, pull a
* model, retry the turn, correct the state, look at the server log. A single
* message leaves the reader guessing which of those they are looking at.
*
* The classification reads the message the server actually sent. That is a
* coupling to backend strings, so it is deliberately a *fallback ladder* rather
* than a lookup: an unrecognised message still gets a kind ("generation"), still
* shows its own text, and still offers Retry. Nothing is hidden when the match
* misses — the reader sees the server's own words either way, and the only thing
* lost is the tailored hint.
*
* The signatures below are the ones `backend/app/providers/openai_compatible.py`
* raises; each is quoted in the comment beside it so a change over there is
* findable from here.
*/
/** The five kinds, in the vocabulary §71 uses. */
export const KIND = {
MODEL: 'model',
GENERATION: 'generation',
STATE: 'state',
KNOWLEDGE: 'knowledge',
SERVER: 'server',
}
const TITLES = {
[KIND.MODEL]: 'Model unavailable',
[KIND.GENERATION]: 'Generation failed',
[KIND.STATE]: 'Story state could not be updated',
[KIND.KNOWLEDGE]: 'Imported knowledge problem',
[KIND.SERVER]: 'The storyteller had a problem',
}
/**
* Classifies a failure message.
*
* Returns `{ kind, title, detail, hint, retryable }`. `detail` is always the
* server's own text — this never replaces what the server said, only frames it.
*/
export function classifyError(message) {
const detail = String(message || '').trim() || 'No detail was reported.'
const low = detail.toLowerCase()
// ---- Model / endpoint: the story cannot be told at all ----
// "No model configured — set one in Settings."
if (low.includes('no model configured')) {
return {
kind: KIND.MODEL,
title: 'No narrator model chosen',
detail,
hint: 'Choose an installed Ollama model in Settings, then try again.',
retryable: false,
action: { label: 'Open Settings', to: '/settings' },
}
}
// "No embedding model configured — set one in Settings."
if (low.includes('no embedding model configured')) {
return {
kind: KIND.KNOWLEDGE,
title: 'No embedding model chosen',
detail,
hint:
'Imported knowledge is still searched by keyword. Choose an embedding '
+ 'model in Settings to add meaning-based search.',
retryable: false,
action: { label: 'Open Settings', to: '/settings' },
}
}
// "Could not connect to <url> — is the AI server running?"
// "Request to AI endpoint failed: ..."
if (low.includes('could not connect') || low.includes('request to ai endpoint failed')) {
return {
kind: KIND.MODEL,
title: 'Ollama is not reachable',
detail,
hint:
'Start Ollama on the machine at the configured endpoint (`ollama serve`), '
+ 'then retry. Nothing you wrote has been lost.',
retryable: true,
}
}
// "The AI endpoint timed out."
if (low.includes('timed out') || low.includes('timeout')) {
return {
kind: KIND.MODEL,
title: 'The model took too long',
detail,
hint:
'Loading a model for the first time can take minutes without a GPU. '
+ 'Retry, or raise the model timeout in Settings.',
retryable: true,
}
}
// "This endpoint can't be used — ..." (the ADR 011 address policy)
if (low.includes("endpoint can't be used") || low.includes('endpoint cannot be used')) {
return {
kind: KIND.MODEL,
title: 'That endpoint is not allowed',
detail,
hint:
'The storyteller only talks to Ollama on this machine or on your own '
+ 'network. Correct the endpoint in Settings.',
retryable: false,
action: { label: 'Open Settings', to: '/settings' },
}
}
// "Endpoint or model not found (HTTP 404). Check ... model '<name>' exists."
if (low.includes('not found') && low.includes('404')) {
return {
kind: KIND.MODEL,
title: 'Endpoint or model not found',
detail,
hint: 'Pull the model on that machine (`ollama pull <model>`), or pick another in Settings.',
retryable: true,
action: { label: 'Open Settings', to: '/settings' },
}
}
// TLS is its own case: the fix is installing a CA, not starting a server.
if (low.includes('certificate') || low.includes('tls') || low.includes('ssl')) {
return {
kind: KIND.MODEL,
title: 'The endpoint’s certificate could not be verified',
detail,
hint:
'If it uses a private or self-signed CA, install that CA on this machine. '
+ 'Certificate checking is not optional.',
retryable: true,
}
}
// ---- State: the turn happened but could not be recorded ----
if (
low.includes('state validation')
|| low.includes('invalid state event')
|| low.includes('narrative state')
|| low.includes('unknown event type')
) {
return {
kind: KIND.STATE,
title: TITLES[KIND.STATE],
detail,
hint:
'The story itself is unaffected. Open State to see what the storyteller '
+ 'currently believes, and correct it if it is wrong.',
retryable: true,
}
}
// ---- Knowledge / derived work ----
if (low.includes('knowledge') || low.includes('embedding') || low.includes('index')) {
return {
kind: KIND.KNOWLEDGE,
title: TITLES[KIND.KNOWLEDGE],
detail,
hint: 'Your story is unaffected. Imported material may not be searched until this is fixed.',
retryable: true,
}
}
// ---- Server / database ----
if (
low.includes('database')
|| low.includes('sqlite')
|| low.includes('integrity')
|| low.includes('internal server error')
|| low.includes('http 500')
) {
return {
kind: KIND.SERVER,
title: TITLES[KIND.SERVER],
detail,
hint: 'Nothing already written has been changed. The server log has the details.',
retryable: true,
}
}
// ---- Anything else is a failed generation ----
//
// Deliberately the fallback rather than a separate "unknown": the reader is
// in the middle of a turn, the turn did not happen, and Retry is the useful
// offer. The server's own words are shown, so nothing is lost by not
// recognising it.
return {
kind: KIND.GENERATION,
title: TITLES[KIND.GENERATION],
detail,
hint: 'Nothing was added to your story. You can try that turn again.',
retryable: true,
}
}
+110
View File
@@ -0,0 +1,110 @@
/* M9: the file picker that recovers a campaign.
*
* `pickJSONFile` is four lines of DOM and was the only control in the product
* with no test at all, for a structural reason: it built a detached
* `<input type="file">` and clicked it, so there was no element for a test — or
* for WebDriver — to hand a file to. The import workflow could therefore only
* ever be checked by calling the API underneath it, which is not the workflow.
*
* Appending the input made it testable, and writing the test found a real bug
* that had been there since the picker was written: closing the dialog without
* choosing anything never settled the promise, so the Campaigns screen's
* `finally` never ran and its Import button stayed disabled reading
* "Importing…" until the page was reloaded. The screen's own comment says a
* cancelled picker is not worth a message — it had just never received one.
*/
import { fireEvent } from '@testing-library/react'
import { beforeEach, describe, expect, it } from 'vitest'
import { pickJSONFile } from './components'
function theInput() {
return document.querySelector('input[type="file"]')
}
/** A `File` the way the browser hands one to a change event. */
function jsonFile(name, contents) {
return new File([JSON.stringify(contents)], name, { type: 'application/json' })
}
/** Puts `files` on the input, since `files` is read-only in jsdom. */
function choose(input, files) {
Object.defineProperty(input, 'files', { value: files, configurable: true })
fireEvent.change(input)
}
beforeEach(() => { document.body.innerHTML = '' })
describe('pickJSONFile', () => {
it('puts a findable input in the document rather than a detached one', () => {
pickJSONFile().catch(() => {})
const input = theInput()
expect(input).toBeInTheDocument()
expect(input.dataset.testid).toBe('import-file')
expect(input.accept).toContain('json')
// Out of sight, out of the tab order and out of the accessibility tree,
// because the reader is looking at the browser's own dialog — but **not**
// `hidden`, which would make it non-interactable and stop the browser
// dispatching `change` when a file is chosen programmatically.
expect(input.hidden).toBe(false)
expect(input.getAttribute('aria-hidden')).toBe('true')
expect(input.tabIndex).toBe(-1)
expect(input.style.position).toBe('fixed')
})
it('resolves with the parsed bundle', async () => {
const promise = pickJSONFile()
choose(theInput(), [jsonFile('c.json', { format: 'ai-dnd-adventure-v3' })])
await expect(promise).resolves.toEqual({ format: 'ai-dnd-adventure-v3' })
})
it('rejects a file that is not JSON, with a message worth showing', async () => {
const promise = pickJSONFile()
const input = theInput()
Object.defineProperty(input, 'files', {
value: [new File(['not json at all'], 'c.json')], configurable: true,
})
fireEvent.change(input)
await expect(promise).rejects.toThrow(/not valid json/i)
})
it('settles even when the file cannot be read, rather than hanging', async () => {
// A real case, not a defensive one. A browser can hand the page a `File`
// whose contents it will not then let the page read — a sandboxed Firefox
// does exactly that for a path outside its confinement, and reports
// `NotFoundError` from the FileReader with the name and size intact.
// Whatever happens, the promise must settle: leaving it pending is what
// left the Import button disabled reading "Importing…".
const promise = pickJSONFile()
const input = theInput()
Object.defineProperty(input, 'files', {
value: [jsonFile('c.json', { ok: true })], configurable: true,
})
fireEvent.change(input)
await expect(Promise.race([
promise.then(() => 'settled', () => 'settled'),
new Promise((r) => { setTimeout(() => r('hung'), 300) }),
])).resolves.toBe('settled')
})
it('settles when the dialog is cancelled, instead of hanging forever', async () => {
const promise = pickJSONFile()
fireEvent(theInput(), new Event('cancel'))
// Rejected, so the caller's `finally` runs — and with no message, so the
// caller shows nothing. Both halves matter: a hang leaves the button
// disabled, and a message would report a decision as a failure.
await expect(promise).rejects.toSatisfy((err) => err.message === '')
})
it('takes the input back out of the document however it settles', async () => {
const resolved = pickJSONFile()
choose(theInput(), [jsonFile('c.json', { ok: true })])
await resolved
expect(theInput()).toBeNull()
const cancelled = pickJSONFile()
fireEvent(theInput(), new Event('cancel'))
await cancelled.catch(() => {})
expect(theInput()).toBeNull()
})
})
+24 -24
View File
@@ -1,30 +1,30 @@
/* The stylesheet, split into sections and imported in order.
The order is load-bearing and must not change. Several selectors in
`tome.css` tie with earlier ones on specificity and win only because they
come later, and `responsive.css` overrides the whole desktop design at
narrow widths. Both are noted where the rules are.
The order is load-bearing: `responsive.css` overrides the desktop design at
narrow widths and must stay last.
Add a new section by adding a file and an `@import` for it. Put the import
where the rules belong in the cascade, not at the end by habit.
M8 removed six sheets with the surfaces they styled — the scenario gallery's
illuminated-tome cards, the stat-schema editor, the branch-map overlay, the
AI Chat scratchpad, the hosted deployment's visitor dashboard, and its log-in
forms. What survived of the last one is `debuglog.css`.
*/
@import './styles/fonts.css'; /* @font-face for the self-hosted families */
@import './styles/tokens.css'; /* custom properties: colors, fonts, spacing */
@import './styles/base.css'; /* scrollbars */
@import './styles/nav.css'; /* the top navigation bar */
@import './styles/forms.css'; /* inputs, labels, and buttons */
@import './styles/cards.css'; /* cards, lists, and the story-card editor */
@import './styles/play.css'; /* the Play screen and the branches panel */
@import './styles/panels.css'; /* the side panel and the in-play script viewer */
@import './styles/drawers.css'; /* the status drawer and the world-state drawer */
@import './styles/schema-editor.css'; /* the stat-schema form and the NPC roster */
@import './styles/insights.css'; /* insights, scripts, and the memory bank */
@import './styles/knowledge.css'; /* the imported knowledge library (M7) */
@import './styles/modals.css'; /* the filter bar, tags, and modals */
@import './styles/auth.css'; /* log in, sign up, and the settings debug log */
@import './styles/banners.css'; /* the Play screen's persistent banner */
@import './styles/tome.css'; /* the illuminated-tome surfaces */
@import './styles/chat.css'; /* the AI Chat scratchpad */
@import './styles/tree-map.css'; /* the branch map overlay */
@import './styles/analytics.css'; /* the visitor dashboard */
@import './styles/responsive.css'; /* narrow screens, 720px and below */
@import './styles/fonts.css'; /* @font-face for the self-hosted families */
@import './styles/tokens.css'; /* custom properties: colors, fonts, spacing */
@import './styles/base.css'; /* scrollbars */
@import './styles/nav.css'; /* the top navigation bar */
@import './styles/forms.css'; /* inputs, labels, and buttons */
@import './styles/library.css'; /* the campaign library, setup, and settings */
@import './styles/story.css'; /* the story screen: transcript and composer */
@import './styles/panels.css'; /* the side panel */
@import './styles/context.css'; /* the context inspector */
@import './styles/knowledge.css'; /* the imported knowledge library */
@import './styles/play.css'; /* take pager, Save Points, state panel */
@import './styles/insights.css'; /* shared report furniture */
@import './styles/cards.css'; /* lists and skeletons */
@import './styles/dialogs.css'; /* modals and classification tags */
@import './styles/modals.css'; /* toasts */
@import './styles/debuglog.css'; /* the settings screen's request log */
@import './styles/responsive.css'; /* narrow screens, 720px and below */
+10 -10
View File
@@ -2,30 +2,30 @@ import React from 'react'
import ReactDOM from 'react-dom/client'
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
import App from './App.jsx'
import Home from './pages/Home.jsx'
import Adventures from './pages/Adventures.jsx'
import Scenarios from './pages/Scenarios.jsx'
import ScenarioEditor from './pages/ScenarioEditor.jsx'
import Campaigns from './pages/Campaigns.jsx'
import NewCampaign from './pages/NewCampaign.jsx'
import Play from './pages/Play'
import Settings from './pages/Settings.jsx'
import Chat from './pages/Chat.jsx'
import { trackKeyboardInset } from './keyboard.js'
import './index.css'
trackKeyboardInset()
/* Four routes. `BROWSER-UX-SPEC.md` §97 puts everything else inside a campaign,
* where it is reachable from the story screen's own panels rather than from a
* URL of its own.
*
* The scenario gallery, the scenario editor and the raw model chat console that
* used to live here are gone; `App.jsx` records why. */
const router = createBrowserRouter([
{
path: '/',
element: <App />,
children: [
{ index: true, element: <Home /> },
{ path: 'adventures', element: <Adventures /> },
{ path: 'scenarios', element: <Scenarios /> },
{ path: 'scenarios/:id', element: <ScenarioEditor /> },
{ index: true, element: <Campaigns /> },
{ path: 'new', element: <NewCampaign /> },
{ path: 'play/:id', element: <Play /> },
{ path: 'settings', element: <Settings /> },
{ path: 'chat', element: <Chat /> },
],
},
])
+286
View File
@@ -0,0 +1,286 @@
/* Safe Markdown for story prose.
*
* The narrator writes Markdown, and so does anything a reader imports. Both
* reach this module, so it is where H06 (stored XSS) and H07 (javascript: URLs)
* are decided for rendered text — the Insights and Knowledge panels decide
* theirs by rendering into a `<pre>` and never coming here at all.
*
* The safety is structural rather than filtered. Every node this returns is a
* React element built from parsed text; the text itself only ever becomes a
* React child, which React escapes. There is no `dangerouslySetInnerHTML` in
* this file, and adding one would be the whole vulnerability — a sanitizer is
* not needed to make markup safe if markup is never produced from input.
*
* So `<script>alert(1)</script>` in a narrator turn is eighteen visible
* characters, and `<img onerror=...>` is likewise just text.
*
* Two further rules come from `BROWSER-UX-SPEC.md`:
*
* §75 a remote image is never fetched. `![alt](https://…)` renders a
* placeholder naming the blocked address, so the reader knows something
* was there without the page reaching the network.
* §74 a link is not followed silently. The href is kept for display, but the
* click is intercepted so the reader can be told they are leaving the
* local-only environment. Anything that is not http/https/mailto — a
* `javascript:` URL above all — never becomes a link at all.
*
* The grammar is deliberately small: headings, emphasis, inline code, links,
* images, lists, blockquotes and fenced code. That is what §76 asks for. Tables,
* footnotes and raw HTML are not supported, and unsupported syntax degrades to
* the literal text the narrator wrote rather than disappearing.
*/
import { Fragment } from 'react'
/** Schemes a link may use. Everything else renders as plain text (H07). */
const SAFE_SCHEMES = ['http:', 'https:', 'mailto:']
/**
* True when `href` is a link we are willing to render as a link.
*
* Parsed with the URL parser rather than matched with a regex, because the
* bypasses are all in the parsing: `java\tscript:`, `JaVaScript:`, and
* `%6a%61vascript:` are the same URL to a browser and different strings to a
* pattern. A relative href resolves against the page and is same-origin, which
* is the local application itself and therefore fine.
*/
function isSafeHref(href) {
if (!href) return false
try {
const url = new URL(href, window.location.origin)
return SAFE_SCHEMES.includes(url.protocol)
} catch {
return false
}
}
/** True when the target leaves this application. */
function isExternal(href) {
try {
const url = new URL(href, window.location.origin)
if (url.protocol === 'mailto:') return true
return url.origin !== window.location.origin
} catch {
return false
}
}
// ---------------------------------------------------------------------------
// Inline
// ---------------------------------------------------------------------------
// One pass, alternation ordered so the longer opener wins: `**` before `*`, and
// the image `![` before the link `[`.
const INLINE = new RegExp(
[
'`([^`\\n]+)`', // 1 code
'!\\[([^\\]]*)\\]\\(([^)\\s]+)[^)]*\\)', // 2 alt, 3 src
'\\[([^\\]]+)\\]\\(([^)\\s]+)[^)]*\\)', // 4 text, 5 href
'\\*\\*([^*\\n]+)\\*\\*', // 6 strong
'__([^_\\n]+)__', // 7 strong
'\\*([^*\\n]+)\\*', // 8 em
'_([^_\\n]+)_', // 9 em
].join('|'),
'g',
)
/**
* Renders one line of inline Markdown to React nodes.
*
* `onLink` is called instead of navigating, so the caller can warn about
* leaving the local environment (§74). Without one, an external link is still
* rendered but does nothing on click rather than navigating silently.
*/
function inline(text, key, onLink) {
if (!text) return null
const out = []
let last = 0
let match
INLINE.lastIndex = 0
while ((match = INLINE.exec(text)) !== null) {
if (match.index > last) out.push(text.slice(last, match.index))
const k = `${key}-${match.index}`
if (match[1] !== undefined) {
out.push(<code key={k}>{match[1]}</code>)
} else if (match[3] !== undefined) {
// §75. The address is shown as text; nothing fetches it.
out.push(
<span key={k} className="md-image-blocked" title={match[3]}>
🚫 Remote image blocked{match[2] ? `: ${match[2]}` : ''}
</span>,
)
} else if (match[5] !== undefined) {
const href = match[5]
out.push(
isSafeHref(href) ? (
<a
key={k}
href={href}
className="md-link"
// Belt and braces for the case where a click still gets through.
rel="noreferrer noopener"
onClick={(e) => {
e.preventDefault()
if (onLink) onLink(href)
}}
>
{match[4]}
</a>
) : (
// Not a scheme we will link. The reader still sees exactly what the
// text said, which is more useful than silently dropping it.
<span key={k} className="md-link-blocked" title="This link was not safe to open">
{match[4]} ({href})
</span>
),
)
} else if (match[6] !== undefined || match[7] !== undefined) {
out.push(<strong key={k}>{match[6] ?? match[7]}</strong>)
} else {
out.push(<em key={k}>{match[8] ?? match[9]}</em>)
}
last = match.index + match[0].length
}
if (last < text.length) out.push(text.slice(last))
return out.length ? out : text
}
// ---------------------------------------------------------------------------
// Block
// ---------------------------------------------------------------------------
const HEADING = /^(#{1,6})\s+(.*)$/
const BULLET = /^\s{0,3}[-*+]\s+(.*)$/
const ORDERED = /^\s{0,3}(\d{1,9})[.)]\s+(.*)$/
const QUOTE = /^\s{0,3}>\s?(.*)$/
const FENCE = /^\s{0,3}(```|~~~)(.*)$/
/**
* Parses `text` into React block elements.
*
* Written as an explicit line cursor rather than a recursive-descent parser
* because the grammar is flat: no construct here nests except a blockquote's
* inline spans, and a hand-rolled loop over lines is far easier to be sure
* about than a general parser would be.
*/
export function Markdown({ text, onLink, className = 'md' }) {
if (!text) return null
const lines = String(text).split('\n')
const blocks = []
let i = 0
let para = []
const flushPara = () => {
if (!para.length) return
const body = para
para = []
blocks.push(
<p key={`p${blocks.length}`}>
{body.map((line, n) => (
<Fragment key={n}>
{n > 0 && <br />}
{inline(line, `p${blocks.length}-${n}`, onLink)}
</Fragment>
))}
</p>,
)
}
while (i < lines.length) {
const line = lines[i]
// Fenced code. Everything up to the closing fence is literal, which is what
// makes a fenced block the safe way to show markup that would otherwise be
// parsed — including markup a hostile import wants rendered.
const fence = FENCE.exec(line)
if (fence) {
flushPara()
const marker = fence[1]
const body = []
i += 1
while (i < lines.length && !lines[i].trimStart().startsWith(marker)) {
body.push(lines[i])
i += 1
}
i += 1 // consume the closing fence, or run off the end
blocks.push(
<pre key={`c${blocks.length}`} className="md-code">
<code>{body.join('\n')}</code>
</pre>,
)
continue
}
const heading = HEADING.exec(line)
if (heading) {
flushPara()
// Story prose sits inside the page's own heading outline, so the largest
// narrator heading is an h3 — a narrator writing `#` must not produce a
// second `<h1>` on a page that already has one.
const level = Math.min(6, heading[1].length + 2)
const Tag = `h${level}`
blocks.push(
<Tag key={`h${blocks.length}`} className="md-heading">
{inline(heading[2], `h${blocks.length}`, onLink)}
</Tag>,
)
i += 1
continue
}
if (QUOTE.test(line)) {
flushPara()
const body = []
while (i < lines.length && QUOTE.test(lines[i])) {
body.push(QUOTE.exec(lines[i])[1])
i += 1
}
blocks.push(
<blockquote key={`q${blocks.length}`} className="md-quote">
{body.map((l, n) => (
<Fragment key={n}>
{n > 0 && <br />}
{inline(l, `q${blocks.length}-${n}`, onLink)}
</Fragment>
))}
</blockquote>,
)
continue
}
if (BULLET.test(line) || ORDERED.test(line)) {
flushPara()
const ordered = !BULLET.test(line)
const items = []
while (i < lines.length) {
const m = ordered ? ORDERED.exec(lines[i]) : BULLET.exec(lines[i])
if (!m) break
items.push(ordered ? m[2] : m[1])
i += 1
}
const Tag = ordered ? 'ol' : 'ul'
blocks.push(
<Tag key={`l${blocks.length}`} className="md-list">
{items.map((item, n) => (
<li key={n}>{inline(item, `l${blocks.length}-${n}`, onLink)}</li>
))}
</Tag>,
)
continue
}
if (line.trim() === '') {
flushPara()
i += 1
continue
}
para.push(line)
i += 1
}
flushPara()
return <div className={className}>{blocks}</div>
}
export { isSafeHref, isExternal }
+133
View File
@@ -0,0 +1,133 @@
/* The safe Markdown renderer.
*
* This file is where H06 and H07 are decided for narrator prose, so these are
* security tests before they are formatting tests. The two that matter most:
* markup in the source never becomes markup in the page, and a `javascript:`
* URL never becomes an href.
*/
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { Markdown, isSafeHref } from './markdown'
describe('safe rendering', () => {
it('renders a script tag as visible text, not as an element (H06)', () => {
const { container } = render(
<Markdown text={'Before <script>alert(1)</script> after'} />,
)
expect(container.querySelector('script')).toBeNull()
expect(container.textContent).toContain('<script>alert(1)</script>')
})
it('renders an img tag with an onerror handler as text (H06)', () => {
const { container } = render(
<Markdown text={'<img src=x onerror="alert(1)">'} />,
)
expect(container.querySelector('img')).toBeNull()
expect(container.textContent).toContain('onerror')
})
it('never creates an href for a javascript: URL (H07)', () => {
const { container } = render(
<Markdown text={'[click me](javascript:alert(1))'} />,
)
expect(container.querySelector('a')).toBeNull()
// The reader still sees what the text said.
expect(container.textContent).toContain('click me')
})
it('refuses data: and vbscript: URLs too', () => {
const { container } = render(
<Markdown text={'[a](data:text/html,<script>1</script>) [b](vbscript:x)'} />,
)
expect(container.querySelector('a')).toBeNull()
})
it('does not fetch a remote image — it renders a placeholder (G09)', () => {
const { container } = render(
<Markdown text={'![a cat](https://example.com/cat.png)'} />,
)
expect(container.querySelector('img')).toBeNull()
expect(screen.getByText(/Remote image blocked/)).toBeInTheDocument()
})
it('intercepts an external link rather than navigating', async () => {
const onLink = vi.fn()
render(<Markdown text={'[site](https://example.com/x)'} onLink={onLink} />)
const link = screen.getByRole('link', { name: 'site' })
link.click()
expect(onLink).toHaveBeenCalledWith('https://example.com/x')
})
})
describe('isSafeHref', () => {
it('accepts http, https and mailto', () => {
expect(isSafeHref('http://a.test')).toBe(true)
expect(isSafeHref('https://a.test')).toBe(true)
expect(isSafeHref('mailto:a@b.test')).toBe(true)
})
it('rejects javascript: however it is spelled', () => {
expect(isSafeHref('javascript:alert(1)')).toBe(false)
expect(isSafeHref('JaVaScRiPt:alert(1)')).toBe(false)
// A tab inside the scheme is stripped by the URL parser, which is exactly
// why parsing beats pattern-matching here.
expect(isSafeHref('java\tscript:alert(1)')).toBe(false)
})
it('rejects an empty or unparseable href', () => {
expect(isSafeHref('')).toBe(false)
expect(isSafeHref(null)).toBe(false)
})
})
describe('formatting (§76)', () => {
it('renders headings, and never above h3', () => {
const { container } = render(<Markdown text={'# Title\n\n## Sub'} />)
// A narrator writing `#` must not produce a second <h1> on the page.
expect(container.querySelector('h1')).toBeNull()
expect(container.querySelector('h3')).toHaveTextContent('Title')
expect(container.querySelector('h4')).toHaveTextContent('Sub')
})
it('renders emphasis', () => {
const { container } = render(<Markdown text={'a **bold** and *italic* line'} />)
expect(container.querySelector('strong')).toHaveTextContent('bold')
expect(container.querySelector('em')).toHaveTextContent('italic')
})
it('renders bullet and ordered lists', () => {
const { container } = render(<Markdown text={'- one\n- two'} />)
expect(container.querySelectorAll('ul li')).toHaveLength(2)
const ordered = render(<Markdown text={'1. one\n2. two'} />)
expect(ordered.container.querySelectorAll('ol li')).toHaveLength(2)
})
it('renders blockquotes and code', () => {
const { container } = render(
<Markdown text={'> quoted\n\n```\nlet x = 1\n```\n\nand `inline`'} />,
)
expect(container.querySelector('blockquote')).toHaveTextContent('quoted')
expect(container.querySelector('pre code')).toHaveTextContent('let x = 1')
expect(container.querySelectorAll('code')).toHaveLength(2)
})
it('keeps markup inside a fenced block literal', () => {
const { container } = render(
<Markdown text={'```\n<script>alert(1)</script>\n```'} />,
)
expect(container.querySelector('script')).toBeNull()
expect(container.querySelector('pre')).toHaveTextContent('<script>alert(1)</script>')
})
it('leaves unsupported syntax as the literal text the narrator wrote', () => {
const { container } = render(<Markdown text={'| a | b |\n| - | - |'} />)
expect(container.querySelector('table')).toBeNull()
expect(container.textContent).toContain('| a | b |')
})
it('renders nothing for empty input', () => {
const { container } = render(<Markdown text={''} />)
expect(container.firstChild).toBeNull()
})
})
+121
View File
@@ -0,0 +1,121 @@
/* Whether the storyteller can actually narrate, and what to do when it cannot.
*
* `BROWSER-UX-SPEC.md` §44 asks the header to say "Ollama: Connected / Model:
* …" or "Ollama unavailable". M8 also inherits a specific piece of debt: the
* settings row can carry an empty `model`, and until now nothing said so until
* a turn failed with a provider error. That is the case §8 of the milestone
* calls out — the reader begins play and meets an obscure backend message.
*
* So this holds one shared answer for the whole application:
*
* checking the test has not come back yet
* ready reachable, and the configured model is installed there
* no-model reachable, but no narrator model is chosen
* missing-model reachable, but the chosen model is not installed there
* unavailable not reachable at all
*
* `no-model` and `missing-model` are separated because the fix differs: choose
* one from a list you already have, versus pull one that is not there. Both are
* resolvable in the browser, which is the point — §8 requires a usable path out
* of a blank model, not a better error about it.
*
* ## Why this is fetched once
*
* The connection test is a real request to Ollama. It runs on mount and when
* something asks for it, and never on a timer: a status line that re-tested
* every few seconds would be a polling loop against the reader's inference
* host, which §38 of the milestone specifically looks for. Anything that
* changes the answer — saving settings, a turn failing — calls `refresh`.
*/
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
import { api } from './api'
const ModelStatusContext = createContext(null)
/** No model may be chosen on the reader's behalf — see `resolve` below. */
export function ModelStatusProvider({ children }) {
const [settings, setSettings] = useState(null)
const [probe, setProbe] = useState(null) // the raw test result
const [checking, setChecking] = useState(true)
// Guards against two refreshes overlapping and the slower one winning.
const runId = useRef(0)
const refresh = useCallback(async () => {
const mine = ++runId.current
setChecking(true)
try {
const fresh = await api.getSettings()
if (runId.current !== mine) return null
setSettings(fresh)
const result = await api.testConnection()
if (runId.current !== mine) return null
setProbe(result)
return result
} catch (err) {
if (runId.current !== mine) return null
setProbe({ ok: false, detail: err.message })
return null
} finally {
if (runId.current === mine) setChecking(false)
}
}, [])
useEffect(() => { refresh() }, [refresh])
const value = useMemo(() => {
const models = probe?.ok ? (probe.models || []) : []
const model = settings?.model || ''
let status = 'checking'
if (!checking) {
if (!probe?.ok) status = 'unavailable'
else if (!model) status = 'no-model'
// An endpoint that lists nothing is not evidence the model is absent —
// some servers answer /models with an empty body. Only claim the model is
// missing when there is a listing to be missing from.
else if (models.length > 0 && !models.includes(model)) status = 'missing-model'
else status = 'ready'
}
return {
status,
checking,
settings,
model,
models,
endpoint: settings?.endpoint_url || '',
embeddingModel: settings?.embedding_model || '',
detail: probe?.ok ? (probe.warning || '') : (probe?.detail || ''),
refresh,
// Writing the chosen model back is done here rather than in the caller so
// the status updates in the same breath as the setting.
async chooseModel(name) {
await api.updateSettings({ ...settings, model: name })
await refresh()
},
}
}, [checking, probe, settings, refresh])
return <ModelStatusContext.Provider value={value}>{children}</ModelStatusContext.Provider>
}
export function useModelStatus() {
const value = useContext(ModelStatusContext)
if (!value) throw new Error('useModelStatus must be used inside ModelStatusProvider')
return value
}
/** The short label for the header. */
export function statusLabel(status) {
switch (status) {
case 'ready': return 'Ollama: Connected'
case 'no-model': return 'No model chosen'
case 'missing-model': return 'Model not installed'
case 'unavailable': return 'Ollama unavailable'
default: return 'Checking Ollama…'
}
}
/** True when a turn cannot succeed, so the composer should say so up front. */
export function blocksPlay(status) {
return status === 'no-model' || status === 'missing-model' || status === 'unavailable'
}
+128
View File
@@ -0,0 +1,128 @@
/* Model status: the five states, and the way out of each one.
*
* §30 names "model-empty/unavailable state". The state machine is the part
* worth testing directly — the difference between "no model chosen" and "the
* chosen model is not installed" is the difference between a list to pick from
* and a command to run, and getting it wrong sends the reader down the wrong
* path.
*/
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from './api'
import { ModelStatusBadge } from './ModelStatusBadge'
import { ModelSetupNotice } from './ModelSetupNotice'
import { mockModelStatus, renderWith, SETTINGS } from './test/helpers'
beforeEach(() => { vi.restoreAllMocks() })
describe('the badge (§44)', () => {
it('reports Connected with the model when everything works', async () => {
mockModelStatus(api)
await renderWith(<ModelStatusBadge />)
const badge = screen.getByTestId('model-status')
expect(badge).toHaveAttribute('data-status', 'ready')
expect(badge).toHaveTextContent('Ollama: Connected')
expect(badge).toHaveTextContent('qwen2.5:3b-instruct')
})
it('reports Ollama unavailable when the endpoint cannot be reached', async () => {
mockModelStatus(api, { ok: false, detail: 'Could not connect to http://127.0.0.1:11434/v1' })
await renderWith(<ModelStatusBadge />)
const badge = screen.getByTestId('model-status')
expect(badge).toHaveAttribute('data-status', 'unavailable')
expect(badge).toHaveTextContent('Ollama unavailable')
})
it('reports a blank model configuration rather than looking healthy', async () => {
mockModelStatus(api, { settings: { ...SETTINGS, model: '' } })
await renderWith(<ModelStatusBadge />)
expect(screen.getByTestId('model-status')).toHaveAttribute('data-status', 'no-model')
})
it('tells a missing model apart from an unchosen one', async () => {
mockModelStatus(api, {
settings: { ...SETTINGS, model: 'not-pulled:latest' },
models: ['qwen2.5:3b-instruct'],
})
await renderWith(<ModelStatusBadge />)
expect(screen.getByTestId('model-status')).toHaveAttribute('data-status', 'missing-model')
})
it('does not claim a model is missing when the endpoint listed nothing', async () => {
// Some servers answer /models with an empty body. An empty listing is not
// evidence the model is absent, and claiming it is would send the reader
// to pull a model they already have.
mockModelStatus(api, { models: [] })
await renderWith(<ModelStatusBadge />)
expect(screen.getByTestId('model-status')).toHaveAttribute('data-status', 'ready')
})
it('is a link to Settings, with the whole sentence as its accessible name', async () => {
mockModelStatus(api)
await renderWith(<ModelStatusBadge />)
const badge = screen.getByTestId('model-status')
expect(badge.tagName).toBe('A')
expect(badge).toHaveAccessibleName(/Open Settings/)
})
})
describe('the way out of a blank model (§8)', () => {
it('offers the installed models to choose from', async () => {
mockModelStatus(api, { settings: { ...SETTINGS, model: '' } })
await renderWith(<ModelSetupNotice />)
expect(screen.getByText('Choose a narrator model')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'qwen2.5:3b-instruct' })).toBeInTheDocument()
})
it('chooses nothing on its own', async () => {
const update = vi.spyOn(api, 'updateSettings').mockResolvedValue({})
mockModelStatus(api, { settings: { ...SETTINGS, model: '' } })
await renderWith(<ModelSetupNotice />)
// Rendering the notice must not write a setting. Picking the first model in
// a listing would silently narrate with whatever sorted first, which may be
// an embedding model.
expect(update).not.toHaveBeenCalled()
})
it('writes the chosen model back when one is picked', async () => {
const user = userEvent.setup()
const update = vi.spyOn(api, 'updateSettings').mockResolvedValue({})
mockModelStatus(api, { settings: { ...SETTINGS, model: '' } })
await renderWith(<ModelSetupNotice />)
await user.click(screen.getByRole('button', { name: 'qwen2.5:3b-instruct' }))
expect(update).toHaveBeenCalledWith(
expect.objectContaining({ model: 'qwen2.5:3b-instruct' }),
)
})
it('warns that an embedding model in the list cannot narrate', async () => {
mockModelStatus(api, { settings: { ...SETTINGS, model: '' } })
await renderWith(<ModelSetupNotice />)
expect(screen.getByText(/for searching your imported material, not for narrating/))
.toBeInTheDocument()
})
it('gives local troubleshooting steps when Ollama is not running', async () => {
mockModelStatus(api, { ok: false, detail: 'Could not connect' })
await renderWith(<ModelSetupNotice />)
expect(screen.getByText('Ollama is not reachable')).toBeInTheDocument()
expect(screen.getByText(/ollama serve/)).toBeInTheDocument()
})
it('says nothing at all when the model is working', async () => {
mockModelStatus(api)
const { container } = await renderWith(<ModelSetupNotice />)
expect(container.querySelector('[data-testid="model-setup-notice"]')).toBeNull()
})
it('offers no cloud provider anywhere (§45)', async () => {
mockModelStatus(api, { ok: false, detail: 'Could not connect' })
const { container } = await renderWith(<ModelSetupNotice />)
const text = container.textContent.toLowerCase()
for (const word of ['openai', 'anthropic', 'openrouter', 'api key', 'sign in']) {
expect(text).not.toContain(word)
}
})
})
-134
View File
@@ -1,134 +0,0 @@
import { useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { api } from '../api'
import { CardSkeleton, downloadJSON, pickJSONFile, ScenarioArt, useToast } from '../components'
export default function Adventures() {
const [adventures, setAdventures] = useState(null)
const [search, setSearch] = useState('')
const navigate = useNavigate()
const toast = useToast()
useEffect(() => {
api.listAdventures().then(setAdventures).catch(() => setAdventures([]))
}, [])
const visible = useMemo(() => {
if (!adventures) return null
const q = search.trim().toLowerCase()
if (!q) return adventures
return adventures.filter((a) =>
`${a.title} ${a.scenario_title || ''}`.toLowerCase().includes(q))
}, [adventures, search])
const remove = async (e, id) => {
e.stopPropagation()
if (!confirm('Delete this adventure permanently?')) return
try {
await api.deleteAdventure(id)
setAdventures(adventures.filter((a) => a.id !== id))
toast('Adventure deleted')
} catch (err) {
toast(err.message, 'error')
}
}
const exportOne = async (e, adv) => {
e.stopPropagation()
try {
const bundle = await api.exportAdventure(adv.id)
const safe = adv.title.replace(/[^\w-]+/g, '_').slice(0, 60) || 'adventure'
downloadJSON(bundle, `${safe}.json`)
} catch (err) {
toast(err.message, 'error')
}
}
const importOne = async () => {
try {
const bundle = await pickJSONFile()
const adv = await api.importAdventure(bundle)
navigate(`/play/${adv.id}`)
} catch (err) {
toast(err.message, 'error')
}
}
return (
<div className="page">
<div className="page-header">
<h1>Adventures</h1>
<div style={{ display: 'flex', gap: 10 }}>
<button onClick={importOne}>Import</button>
<button className="primary" onClick={() => navigate('/scenarios')}>
+ New Adventure
</button>
</div>
</div>
{adventures?.length > 0 && (
<div className="filter-bar">
<input
type="text"
className="search-input"
placeholder="Search adventures…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
)}
{visible === null ? (
<CardSkeleton count={4} lines={3} />
) : visible.length === 0 ? (
<div className="empty">
{adventures.length === 0
? 'No adventures yet. Head to Scenarios to begin your first story.'
: 'No adventures match your search.'}
</div>
) : (
<div className="card-grid wide">
{visible.map((adv, i) => (
<article
key={adv.id}
className="card tome enter"
style={{ animationDelay: `${Math.min(i, 8) * 50}ms` }}
onClick={() => navigate(`/play/${adv.id}`)}
>
<div className="card-head">
<ScenarioArt image={adv.image_url} icon={adv.icon} title={adv.title} large />
<div className="card-headings">
<h3>{adv.title}</h3>
<p className="card-from">
{/* Adventures inherit the scenario's title, so only name
the source when it actually differs. */}
{adv.scenario_title && adv.scenario_title !== adv.title
? `From “${adv.scenario_title}” · `
: ''}
{adv.action_count} {adv.action_count === 1 ? 'turn' : 'turns'}
</p>
</div>
</div>
{adv.snippet && <p className="snippet">{adv.snippet}</p>}
<footer className="card-foot">
<span className="turns">
Last played {new Date(adv.updated_at + 'Z').toLocaleDateString()}
</span>
<span className="card-actions">
<button className="tiny" title="Export as JSON backup" onClick={(e) => exportOne(e, adv)}>
Export
</button>
<button className="tiny danger" onClick={(e) => remove(e, adv.id)}>
Delete
</button>
</span>
</footer>
</article>
))}
</div>
)}
</div>
)
}
+189
View File
@@ -0,0 +1,189 @@
/* The campaign library — the landing page, and the only screen above a campaign.
*
* `BROWSER-UX-SPEC.md` §40 and §99. It replaces two upstream screens that
* listed the same rows twice: a Home page with a "Continue" strip above a
* scenario gallery, and an Adventures index. One list, everything you can do to
* a campaign on its card.
*
* Nothing here shows an id. A campaign is identified by its title and when it
* was last played, because those are what a person recognises; the integer in
* the URL is an implementation detail and stays there.
*
* Deleting is the one destructive action reachable from this screen, so it is
* the one thing behind a typed confirmation (§67).
*/
import { useEffect, useMemo, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { api } from '../api'
import { CardSkeleton, downloadJSON, pickJSONFile, useToast } from '../components'
import { ConfirmDialog } from '../Dialog'
import { classifyError } from '../errors'
/** "3 minutes ago" / "yesterday" / a date, from a naive-UTC timestamp. */
function relativeTime(iso) {
if (!iso) return 'never'
const then = new Date(iso.endsWith('Z') ? iso : iso + 'Z')
const minutes = Math.round((Date.now() - then.getTime()) / 60000)
if (minutes < 2) return 'just now'
if (minutes < 60) return `${minutes} minutes ago`
const hours = Math.round(minutes / 60)
if (hours < 24) return hours === 1 ? 'an hour ago' : `${hours} hours ago`
const days = Math.round(hours / 24)
if (days === 1) return 'yesterday'
if (days < 7) return `${days} days ago`
return then.toLocaleDateString()
}
export default function Campaigns() {
const [campaigns, setCampaigns] = useState(null)
const [failed, setFailed] = useState(null)
const [confirming, setConfirming] = useState(null) // the campaign awaiting a typed name
const [importing, setImporting] = useState(false)
const navigate = useNavigate()
const toast = useToast()
useEffect(() => {
let cancelled = false
api.listAdventures()
.then((list) => { if (!cancelled) setCampaigns(list) })
.catch((err) => { if (!cancelled) setFailed(classifyError(err.message)) })
return () => { cancelled = true }
}, [])
const empty = campaigns !== null && campaigns.length === 0
const remove = async (campaign) => {
try {
await api.deleteAdventure(campaign.id)
setCampaigns((list) => list.filter((c) => c.id !== campaign.id))
setConfirming(null)
toast(`“${campaign.title}” was deleted.`)
} catch (err) {
toast(classifyError(err.message).detail, 'error')
}
}
const exportOne = async (campaign) => {
try {
const bundle = await api.exportAdventure(campaign.id)
const safe = (campaign.title || 'campaign').replace(/[^\w-]+/g, '_').slice(0, 60)
downloadJSON(bundle, `${safe}.json`)
toast('Campaign exported.')
} catch (err) {
toast(classifyError(err.message).detail, 'error')
}
}
const importOne = async () => {
setImporting(true)
try {
const bundle = await pickJSONFile()
const campaign = await api.importAdventure(bundle)
navigate(`/play/${campaign.id}`)
} catch (err) {
// A cancelled file picker is not a failure worth a message.
if (err?.message) toast(classifyError(err.message).detail, 'error')
} finally {
setImporting(false)
}
}
const cards = useMemo(() => campaigns || [], [campaigns])
return (
<div className="page library">
<header className="library-head">
<div>
<h1>Your campaigns</h1>
<p className="library-sub">
Everything here is stored on this machine.
</p>
</div>
<div className="library-actions">
<button type="button" onClick={importOne} disabled={importing}>
{importing ? 'Importing…' : 'Import campaign'}
</button>
<Link className="button primary" to="/new">New campaign</Link>
</div>
</header>
{failed && (
<div className="notice error" role="alert">
<strong>{failed.title}</strong>
<span>{failed.detail}</span>
</div>
)}
{campaigns === null && !failed && <CardSkeleton count={3} lines={2} />}
{empty && (
<div className="library-empty">
<h2>No campaigns yet</h2>
<p>
A campaign is one continuous story. Start one and write the first
thing your character does — everything else, from what the story
believes to the material you import into it, grows from there.
</p>
<Link className="button primary" to="/new">Start your first campaign</Link>
</div>
)}
{cards.length > 0 && (
<ul className="campaign-grid">
{cards.map((c) => (
<li key={c.id} className="campaign-card">
{/* The whole title is the link, so the card has exactly one
navigation target rather than an onClick on a <div>. */}
<h2 className="campaign-title">
<Link to={`/play/${c.id}`}>{c.title || 'Untitled campaign'}</Link>
</h2>
<p className="campaign-meta">
{c.action_count} {c.action_count === 1 ? 'moment' : 'moments'}
{' · '}
last played {relativeTime(c.updated_at)}
</p>
{c.snippet
? <p className="campaign-snippet">{c.snippet}</p>
: <p className="campaign-snippet muted">Not a word written yet.</p>}
<div className="campaign-tools">
<Link className="button" to={`/play/${c.id}`}>Open</Link>
<button type="button" onClick={() => exportOne(c)}>Export</button>
<button
type="button"
className="danger"
onClick={() => setConfirming(c)}
>
Delete
</button>
</div>
</li>
))}
</ul>
)}
{confirming && (
<ConfirmDialog
title={`Delete “${confirming.title}”?`}
confirmLabel="Delete this campaign"
destructive
// §67: the stronger confirmation. Deleting a campaign destroys the
// story, its Save Points, its state and its imported knowledge, and
// nothing else in the product does that.
requireText={confirming.title}
requireLabel="Type the campaign name to confirm"
onCancel={() => setConfirming(null)}
onConfirm={() => remove(confirming)}
>
<p>
This permanently deletes the whole story, every Save Point, the
story state, and everything imported into it. It cannot be undone.
</p>
<p>
If you might want it back, export it first.
</p>
</ConfirmDialog>
)}
</div>
)
}
-318
View File
@@ -1,318 +0,0 @@
/* AI Chat — a plain scratchpad for talking to the configured model, with none
of the game's context assembly in the way. Useful for checking a model or a
prompt without starting an adventure.
Deliberately client-side: the conversation lives in localStorage, not the
database. Nothing here is part of an adventure, so there's nothing worth a
migration — and a refresh still keeps what you were poking at. */
import { useCallback, useEffect, useRef, useState } from 'react'
import { api } from '../api'
import { useToast } from '../components'
const STORAGE_KEY = 'aidnd.chat.v1'
function load() {
try {
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}')
return {
messages: Array.isArray(saved.messages) ? saved.messages : [],
system: typeof saved.system === 'string' ? saved.system : '',
model: typeof saved.model === 'string' ? saved.model : '',
temperature: saved.temperature ?? '',
maxTokens: saved.maxTokens ?? '',
}
} catch {
return { messages: [], system: '', model: '', temperature: '', maxTokens: '' }
}
}
const ROLE_LABEL = { user: 'You', assistant: 'AI', system: 'System' }
function ReasoningBlock({ text, streaming }) {
if (!text) return null
return (
<details className="reasoning" open={streaming || undefined}>
<summary>💭 Reasoning{streaming ? '…' : ''}</summary>
<div className="reasoning-text">{text}</div>
</details>
)
}
function Message({ message, onDelete }) {
const [copied, setCopied] = useState(false)
const copy = () => {
navigator.clipboard?.writeText(message.content).then(
() => { setCopied(true); setTimeout(() => setCopied(false), 1500) },
() => {},
)
}
return (
<div className={`chat-msg ${message.role}`}>
<div className="chat-msg-head">
<span className="chat-role">{ROLE_LABEL[message.role] || message.role}</span>
{message.model && <span className="dim chat-model-tag">{message.model}</span>}
<span className="chat-msg-actions">
<button className="linklike" onClick={copy}>{copied ? 'copied' : 'copy'}</button>
<button className="linklike" onClick={onDelete}>delete</button>
</span>
</div>
<ReasoningBlock text={message.reasoning} />
<div className="chat-msg-body">{message.content}</div>
</div>
)
}
export default function Chat() {
const toast = useToast()
const initial = useRef(load()).current
const [messages, setMessages] = useState(initial.messages)
const [system, setSystem] = useState(initial.system)
const [model, setModel] = useState(initial.model)
const [temperature, setTemperature] = useState(initial.temperature)
const [maxTokens, setMaxTokens] = useState(initial.maxTokens)
const [input, setInput] = useState('')
const [config, setConfig] = useState(null)
const [showOptions, setShowOptions] = useState(false)
// Streaming reply in progress: null when idle, else the text so far ('' before
// the first token). `busy` covers the whole request, including the wait.
const [streaming, setStreaming] = useState(null)
const [reasoningStream, setReasoningStream] = useState(null)
const [busy, setBusy] = useState(false)
const abortRef = useRef(null)
const inputRef = useRef(null)
const pinnedRef = useRef(true)
useEffect(() => {
api.getChatConfig().then(setConfig).catch(() => setConfig(null))
}, [])
useEffect(() => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ messages, system, model, temperature, maxTokens }),
)
}, [messages, system, model, temperature, maxTokens])
// Grow the composer with its content (CSS caps the height, then it scrolls).
useEffect(() => {
const el = inputRef.current
if (!el) return
el.style.height = 'auto'
el.style.height = `${el.scrollHeight}px`
}, [input])
useEffect(() => {
const onScroll = () => {
pinnedRef.current =
window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 120
}
window.addEventListener('scroll', onScroll, { passive: true })
return () => window.removeEventListener('scroll', onScroll)
}, [])
useEffect(() => {
if (pinnedRef.current) window.scrollTo({ top: document.documentElement.scrollHeight })
}, [messages, streaming, reasoningStream])
// Abort any in-flight stream when leaving the page.
useEffect(() => () => abortRef.current?.abort(), [])
const send = useCallback(async (history) => {
const controller = new AbortController()
abortRef.current = controller
setBusy(true)
setStreaming('')
setReasoningStream(null)
pinnedRef.current = true
const payload = {
messages: [
...(system.trim() ? [{ role: 'system', content: system.trim() }] : []),
...history.map(({ role, content }) => ({ role, content })),
],
}
if (model.trim()) payload.model = model.trim()
if (temperature !== '' && temperature !== null) payload.temperature = Number(temperature)
if (maxTokens !== '' && maxTokens !== null) payload.max_tokens = Number(maxTokens)
let reasoning = ''
try {
await api.chatStream(payload, (event) => {
if (event.type === 'chunk') {
setStreaming((prev) => (prev ?? '') + event.text)
} else if (event.type === 'reasoning') {
reasoning += event.text
setReasoningStream((prev) => (prev ?? '') + event.text)
} else if (event.type === 'note') {
toast(event.detail)
} else if (event.type === 'done') {
setMessages((prev) => [...prev, {
role: 'assistant',
content: event.text,
reasoning: event.reasoning || reasoning || undefined,
model: event.model,
}])
} else if (event.type === 'error') {
toast(event.detail, 'error')
}
}, controller.signal)
} catch (err) {
if (err.name !== 'AbortError') toast(err.message, 'error')
} finally {
abortRef.current = null
setBusy(false)
setStreaming(null)
setReasoningStream(null)
}
}, [system, model, temperature, maxTokens, toast])
const submit = () => {
const text = input.trim()
if (!text || busy) return
const history = [...messages, { role: 'user', content: text }]
setMessages(history)
setInput('')
send(history)
}
const regenerate = () => {
if (busy) return
// Drop trailing assistant replies and re-send from the last user message.
let history = [...messages]
while (history.length && history[history.length - 1].role === 'assistant') history.pop()
if (!history.length) return
setMessages(history)
send(history)
}
const stop = () => {
abortRef.current?.abort()
// Keep whatever streamed in — a cut-off reply is often the thing you wanted.
const partial = streaming?.trim()
if (partial) {
setMessages((prev) => [...prev, {
role: 'assistant',
content: partial,
reasoning: reasoningStream || undefined,
model: config?.model,
stopped: true,
}])
}
}
const clear = () => {
if (busy || !messages.length) return
setMessages([])
toast('Conversation cleared')
}
const deleteAt = (index) => setMessages((prev) => prev.filter((_, i) => i !== index))
const onKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
submit()
}
}
const waitingForFirstToken = streaming === '' && reasoningStream === null
const canRegenerate = !busy && messages.some((m) => m.role === 'user')
return (
<div className="page chat-page">
<div className="page-header">
<h1>AI Chat</h1>
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<button className="linklike" onClick={() => setShowOptions((o) => !o)}>
{showOptions ? 'hide options' : 'options'}
</button>
<button onClick={regenerate} disabled={!canRegenerate}>Regenerate</button>
<button className="danger" onClick={clear} disabled={busy || !messages.length}>Clear</button>
</div>
</div>
<div className="chat-meta dim">
{config
? <>
{/* The override wins when set, so show what a send would actually use. */}
{model.trim() || config.model || '(no model set)'} · {config.endpoint_url}
{config.using_demo && ' · shared demo key (whitelisted models only)'}
{config.api_mode === 'completion' && ' · completion mode (messages are flattened)'}
</>
: 'Loading provider config…'}
</div>
{showOptions && (
<div className="chat-options">
<label className="field">
<span className="label">System prompt (sent first, every turn — empty = none)</span>
<textarea rows={3} value={system} placeholder="You are a helpful assistant."
onChange={(e) => setSystem(e.target.value)} />
</label>
<div style={{ display: 'flex', gap: 14, flexWrap: 'wrap' }}>
<label className="field" style={{ flex: '2 1 240px' }}>
<span className="label">Model {config?.using_demo ? '(demo whitelist)' : '(empty = Settings default)'}</span>
<input type="text" list="chat-models" value={model} placeholder={config?.model || 'model slug'}
onChange={(e) => setModel(e.target.value)} />
<datalist id="chat-models">
{(config?.models || []).map((m) => <option key={m} value={m} />)}
</datalist>
</label>
<label className="field" style={{ flex: '1 1 110px' }}>
<span className="label">Temperature</span>
<input type="number" step="0.1" min="0" max="5" value={temperature}
placeholder={config?.temperature ?? ''}
onChange={(e) => setTemperature(e.target.value)} />
</label>
<label className="field" style={{ flex: '1 1 130px' }}>
<span className="label">Max tokens</span>
<input type="number" min="1" value={maxTokens}
placeholder={config?.max_tokens ?? ''}
onChange={(e) => setMaxTokens(e.target.value)} />
</label>
</div>
{config?.models_error && (
<div className="dim" style={{ fontSize: '0.82rem' }}>
Couldn't list models from the endpoint: {config.models_error}
</div>
)}
</div>
)}
<div className="chat-transcript">
{!messages.length && streaming === null && (
<div className="empty">
Nothing here yet — no story, no scripts, no world state. Just you and the model.
</div>
)}
{messages.map((m, i) => (
<Message key={i} message={m} onDelete={() => deleteAt(i)} />
))}
{streaming !== null && (
<div className="chat-msg assistant">
<div className="chat-msg-head">
<span className="chat-role">AI</span>
<span className="dim chat-model-tag">{model.trim() || config?.model}</span>
</div>
<ReasoningBlock text={reasoningStream} streaming />
{waitingForFirstToken
? <div className="thinking" role="status"><i /><i /><i /><span>Thinking</span></div>
: <div className="chat-msg-body">{streaming}<span className="cursor">▋</span></div>}
</div>
)}
</div>
<div className="chat-composer">
<textarea ref={inputRef} rows={1} value={input} onChange={(e) => setInput(e.target.value)}
onKeyDown={onKeyDown} placeholder="Message the model… (Enter to send, Shift+Enter for a new line)" />
{busy
? <button className="danger" onClick={stop}>Stop</button>
: <button className="primary" onClick={submit} disabled={!input.trim()}>Send</button>}
</div>
</div>
)
}
-237
View File
@@ -1,237 +0,0 @@
import { useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { api } from '../api'
import {
CardSkeleton,
extractPlaceholders,
BeginAdventureModal,
ScenarioArt,
useToast,
} from '../components'
// How many in-progress stories the landing page shows before deferring to
// "See all". Two rows on a wide screen; enough to recognise, not a full index.
const CONTINUE_LIMIT = 4
const SCENARIO_LIMIT = 6
function splitTags(tags, { isPublic = false } = {}) {
const all = (tags || '').split(',').map((t) => t.trim()).filter(Boolean)
// Public scenarios already carry a "demo ✦" badge; the tag would repeat it.
return isPublic ? all.filter((t) => t.toLowerCase() !== 'demo') : all
}
function relativeTime(iso) {
// Stored timestamps are naive UTC, hence the appended Z (matches the rest of
// the app's date handling).
const then = new Date(iso + 'Z')
const minutes = Math.round((Date.now() - then.getTime()) / 60000)
if (minutes < 2) return 'just now'
if (minutes < 60) return `${minutes} min ago`
const hours = Math.round(minutes / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.round(hours / 24)
if (days < 7) return `${days}d ago`
return then.toLocaleDateString()
}
/** Section heading framed by ornamental rules — the illuminated-tome motif. */
function Rule({ children, action }) {
return (
<div className="rule-head">
<h2 className="rule">
<span>{children}</span>
</h2>
{action}
</div>
)
}
export default function Home() {
const [adventures, setAdventures] = useState(null)
const [scenarios, setScenarios] = useState(null)
const [pending, setPending] = useState(null) // { scenario, names } awaiting placeholders
const navigate = useNavigate()
const toast = useToast()
useEffect(() => {
api.listAdventures().then(setAdventures).catch(() => setAdventures([]))
api.listScenarios().then(setScenarios).catch(() => setScenarios([]))
}, [])
const ongoing = useMemo(() => (adventures || []).slice(0, CONTINUE_LIMIT), [adventures])
const featured = useMemo(() => (scenarios || []).slice(0, SCENARIO_LIMIT), [scenarios])
const begin = async (scenarioId, { persona = {}, placeholders = {} } = {}) => {
try {
const adv = await api.createAdventure({
scenario_id: scenarioId,
placeholders,
persona_name: persona.name || '',
persona_pronouns: persona.pronouns || '',
persona_desc: persona.desc || '',
})
navigate(`/play/${adv.id}`)
} catch (err) {
toast(err.message, 'error')
}
}
const startAdventure = async (e, scenarioId) => {
e.stopPropagation()
try {
const scenario = await api.getScenario(scenarioId)
const names = extractPlaceholders(
scenario.prompt, scenario.memory, scenario.authors_note, scenario.ai_instructions,
...scenario.story_cards.flatMap((c) => [c.keys, c.entry]),
)
// Always open the modal, even with no placeholders: it is where the
// player names their character.
setPending({ scenario, names })
} catch (err) {
toast(err.message, 'error')
}
}
const loading = adventures === null || scenarios === null
const nothingAtAll = !loading && adventures.length === 0 && scenarios.length === 0
return (
<div className="page">
{/* Returning players want their story first, so the only thing above the
fold is a compact banner — no full-height splash. */}
<header className="hall">
<p className="hall-eyebrow">Welcome back</p>
<h1 className="hall-title">The table is set</h1>
<p className="hall-sub">
{loading
? 'Gathering your stories…'
: adventures.length > 0
? `${adventures.length} ${adventures.length === 1 ? 'story' : 'stories'} in progress · ${scenarios.length} ${scenarios.length === 1 ? 'world' : 'worlds'} to explore`
: `${scenarios.length} ${scenarios.length === 1 ? 'world' : 'worlds'} waiting for a first line`}
</p>
</header>
{/* ---------- Continue ---------- */}
{(loading || adventures.length > 0) && (
<section className="home-section">
<Rule
action={
adventures?.length > CONTINUE_LIMIT ? (
<button className="linklike ornate" onClick={() => navigate('/adventures')}>
See all {adventures.length} ❖
</button>
) : null
}
>
Continue
</Rule>
{loading ? (
<CardSkeleton count={2} lines={3} />
) : (
<div className="card-grid wide">
{ongoing.map((adv, i) => (
<article
key={adv.id}
className="card tome enter"
style={{ animationDelay: `${i * 60}ms` }}
onClick={() => navigate(`/play/${adv.id}`)}
>
<span className="seal" title="In progress" aria-hidden="true">✦</span>
<div className="card-head">
<ScenarioArt image={adv.image_url} icon={adv.icon} title={adv.title} large />
<div className="card-headings">
<h3>{adv.title}</h3>
{/* An adventure created from a scenario inherits its
title, so showing both just prints it twice. */}
{adv.scenario_title && adv.scenario_title !== adv.title && (
<p className="card-from">From “{adv.scenario_title}”</p>
)}
</div>
</div>
{adv.snippet ? (
<p className="snippet dropcap">{adv.snippet}</p>
) : (
<p className="snippet muted">Not a word written yet. Open it and begin.</p>
)}
<footer className="card-foot">
<span className="turns">
{adv.action_count} {adv.action_count === 1 ? 'turn' : 'turns'}
</span>
<span className="dot" aria-hidden="true">·</span>
<span className="turns">{relativeTime(adv.updated_at)}</span>
<span className="resume">Resume →</span>
</footer>
</article>
))}
</div>
)}
</section>
)}
{/* ---------- Scenarios ---------- */}
<section className="home-section">
<Rule
action={
scenarios?.length > SCENARIO_LIMIT ? (
<button className="linklike ornate" onClick={() => navigate('/scenarios')}>
See all {scenarios.length} ❖
</button>
) : null
}
>
{adventures?.length > 0 ? 'Begin a new story' : 'Choose a world'}
</Rule>
{loading ? (
<CardSkeleton count={3} />
) : nothingAtAll ? (
<div className="empty">
Nothing here yet. Create a scenario to define your first world.
</div>
) : (
<div className="card-grid">
{featured.map((sc, i) => (
<article
key={sc.id}
className="card tome enter"
style={{ animationDelay: `${i * 60}ms` }}
onClick={() => navigate(`/scenarios/${sc.id}`)}
>
<div className="card-head">
<ScenarioArt image={sc.image_url} icon={sc.icon} title={sc.title} />
<div className="card-headings">
<h3>{sc.title}</h3>
</div>
</div>
<p className="snippet">{sc.description || 'No description yet.'}</p>
<footer className="card-foot">
<div className="tag-cluster">
{sc.is_public && (
<span className="tag small" title="Shared demo scenario (read-only)">demo ✦</span>
)}
{splitTags(sc.tags, { isPublic: sc.is_public }).slice(0, 2).map((tag) => (
<span key={tag} className="tag small">{tag}</span>
))}
</div>
<button className="primary compact" onClick={(e) => startAdventure(e, sc.id)}>
Play
</button>
</footer>
</article>
))}
</div>
)}
</section>
{pending && (
<BeginAdventureModal
title={pending.scenario.title}
names={pending.names}
onCancel={() => setPending(null)}
onSubmit={(answers) => { setPending(null); begin(pending.scenario.id, answers) }}
/>
)}
</div>
)
}
+298
View File
@@ -0,0 +1,298 @@
/* Starting a campaign, in one screen.
*
* `BROWSER-UX-SPEC.md` §41-42 and §7 of the M8 brief. What it replaced was a
* scenario gallery followed by a placeholder modal: to begin a story you first
* picked a *world*, and to make a world you opened an editor with a JSON stat
* schema, a story-card table and an art picker. That is a fine way to build a
* D&D module and a poor way to start a mystery.
*
* Two rules shaped this form.
*
* **Only the name is required.** Everything else has a sensible blank. A reader
* who types "Westhaven" and presses Start gets a working campaign and can write
* the first line themselves; the rest of the fields are there for someone who
* already knows what they want. §7 is explicit that the data model must not be
* a gate on beginning.
*
* **No genre is privileged.** There is no fantasy vocabulary in the labels, the
* placeholders or the composed instructions — the same form has to fit a
* western and a horror story. Genre and tone are free text with suggestions
* rather than a closed list, because a closed list is a judgement about which
* stories this product is for.
*
* The style answers are composed into the campaign's narrator instructions
* (`ai_instructions`), which is the field the prompt builder already reads. No
* new backend concept was invented to hold "tone" — it is a sentence in the
* instructions, which is what it would have to become anyway.
*/
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { api } from '../api'
import { useToast } from '../components'
import { classifyError } from '../errors'
import { blocksPlay, useModelStatus } from '../modelStatus'
import { ModelSetupNotice } from '../ModelSetupNotice'
const GENRES = [
'Fantasy', 'Science fiction', 'Mystery', 'Historical',
'Western', 'Horror', 'Thriller', 'Literary',
]
const TONES = [
'Grounded and restrained', 'Warm and hopeful', 'Bleak', 'Wry',
'Tense', 'Whimsical', 'Epic',
]
const POV = [
{ value: 'second-present', label: 'Second person, present tense ("You walk…")' },
{ value: 'first-past', label: 'First person, past tense ("I walked…")' },
{ value: 'third-past', label: 'Third person, past tense ("She walked…")' },
{ value: 'third-present', label: 'Third person, present tense ("She walks…")' },
]
const LENGTHS = [
{ value: 'brief', label: 'Brief — a paragraph or two' },
{ value: 'medium', label: 'Medium — two to four paragraphs' },
{ value: 'long', label: 'Long — four or more paragraphs' },
]
const POV_SENTENCE = {
'second-present': 'Write in second person, present tense.',
'first-past': 'Write in first person, past tense.',
'third-past': 'Write in third person, past tense.',
'third-present': 'Write in third person, present tense.',
}
const LENGTH_SENTENCE = {
brief: 'Keep responses brief — one or two paragraphs.',
medium: 'Keep responses to roughly two to four paragraphs.',
long: 'Write at length — four or more paragraphs.',
}
/**
* Turns the style answers into narrator instructions.
*
* Exported because the composition is the one piece of real logic on this
* screen and a component test can check it without rendering anything.
*/
export function composeInstructions({ genre, tone, pov, length, style }) {
const lines = []
if (genre.trim()) lines.push(`This is a ${genre.trim().toLowerCase()} story.`)
if (tone.trim()) lines.push(`The tone is ${tone.trim().toLowerCase()}.`)
if (POV_SENTENCE[pov]) lines.push(POV_SENTENCE[pov])
if (LENGTH_SENTENCE[length]) lines.push(LENGTH_SENTENCE[length])
// Always last, so a reader's own words can override anything above them.
if (style.trim()) lines.push(style.trim())
return lines.join(' ')
}
/** A text field with a datalist of suggestions — open, not a closed set. */
function SuggestField({ id, label, value, onChange, options, placeholder, hint }) {
return (
<div className="field">
<label htmlFor={id}>{label}</label>
<input
id={id}
type="text"
value={value}
list={`${id}-options`}
placeholder={placeholder}
autoComplete="off"
onChange={(e) => onChange(e.target.value)}
/>
<datalist id={`${id}-options`}>
{options.map((o) => <option key={o} value={o} />)}
</datalist>
{hint && <p className="field-hint">{hint}</p>}
</div>
)
}
export default function NewCampaign() {
const navigate = useNavigate()
const toast = useToast()
const { status } = useModelStatus()
const [title, setTitle] = useState('')
const [genre, setGenre] = useState('')
const [tone, setTone] = useState('')
const [pov, setPov] = useState('second-present')
const [length, setLength] = useState('medium')
const [style, setStyle] = useState('')
const [protagonist, setProtagonist] = useState('')
const [pronouns, setPronouns] = useState('')
const [protagonistDesc, setProtagonistDesc] = useState('')
const [opening, setOpening] = useState('')
const [canon, setCanon] = useState('')
const [creating, setCreating] = useState(false)
const start = async (event) => {
event.preventDefault()
if (!title.trim() || creating) return
setCreating(true)
try {
const campaign = await api.createAdventure({
title: title.trim(),
opening: opening.trim(),
// One rule per line is the lightest editor that produces a list, and it
// is what a person writing rules actually does.
canon_rules: canon.split('\n').map((r) => r.trim()).filter(Boolean),
persona_name: protagonist.trim(),
persona_pronouns: pronouns.trim(),
persona_desc: protagonistDesc.trim(),
})
const instructions = composeInstructions({ genre, tone, pov, length, style })
if (instructions) {
await api.updateAdventure(campaign.id, { ai_instructions: instructions })
}
navigate(`/play/${campaign.id}`)
} catch (err) {
toast(classifyError(err.message).detail, 'error')
setCreating(false)
}
}
return (
<div className="page setup">
<h1>New campaign</h1>
<p className="setup-lede">
Only a name is needed. Everything else can be changed later, or left to
the story to decide.
</p>
{blocksPlay(status) && <ModelSetupNotice />}
<form onSubmit={start}>
<section className="setup-section">
<h2>Name</h2>
<div className="field">
<label htmlFor="campaign-title">What is this story called?</label>
<input
id="campaign-title"
type="text"
required
autoFocus
maxLength={120}
value={title}
placeholder="The Westhaven Enquiry"
onChange={(e) => setTitle(e.target.value)}
/>
</div>
</section>
<section className="setup-section">
<h2>Story profile</h2>
<div className="field-row">
<SuggestField
id="campaign-genre" label="Genre" value={genre} onChange={setGenre}
options={GENRES} placeholder="Mystery"
/>
<SuggestField
id="campaign-tone" label="Tone" value={tone} onChange={setTone}
options={TONES} placeholder="Grounded and restrained"
/>
</div>
<div className="field-row">
<div className="field">
<label htmlFor="campaign-pov">Voice</label>
<select id="campaign-pov" value={pov} onChange={(e) => setPov(e.target.value)}>
{POV.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
<div className="field">
<label htmlFor="campaign-length">Narration length</label>
<select
id="campaign-length" value={length}
onChange={(e) => setLength(e.target.value)}
>
{LENGTHS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
</div>
<div className="field">
<label htmlFor="campaign-style">Anything else the narrator should know</label>
<textarea
id="campaign-style" rows={2} maxLength={2000} value={style}
placeholder="Never resolve a scene with violence without warning me first."
onChange={(e) => setStyle(e.target.value)}
/>
</div>
</section>
<section className="setup-section">
<h2>Protagonist</h2>
<div className="field-row">
<div className="field">
<label htmlFor="campaign-protagonist">Name</label>
<input
id="campaign-protagonist" type="text" maxLength={80} value={protagonist}
placeholder="Aldric" autoComplete="off"
onChange={(e) => setProtagonist(e.target.value)}
/>
</div>
<div className="field">
<label htmlFor="campaign-pronouns">Pronouns</label>
<input
id="campaign-pronouns" type="text" maxLength={40} value={pronouns}
placeholder="they/them" autoComplete="off"
onChange={(e) => setPronouns(e.target.value)}
/>
</div>
</div>
<div className="field">
<label htmlFor="campaign-protagonist-desc">Who are they?</label>
<textarea
id="campaign-protagonist-desc" rows={2} maxLength={2000} value={protagonistDesc}
placeholder="A traveling investigator, patient and hard to lie to."
onChange={(e) => setProtagonistDesc(e.target.value)}
/>
</div>
</section>
<section className="setup-section">
<h2>Opening scene</h2>
<div className="field">
<label htmlFor="campaign-opening">Where does the story begin?</label>
<textarea
id="campaign-opening" rows={4} maxLength={8000} value={opening}
placeholder={
'You sit at a shared table in the Crooked Lantern Tavern. In your '
+ 'pocket is a small silver key you took from a missing scholar’s desk.'
}
onChange={(e) => setOpening(e.target.value)}
/>
<p className="field-hint">
Left blank, the story opens on an empty page and begins with
whatever you write first.
</p>
</div>
</section>
<section className="setup-section">
<h2>Canon</h2>
<div className="field">
<label htmlFor="campaign-canon">
What is true in this story, and must stay true?
</label>
<textarea
id="campaign-canon" rows={4} maxLength={8000} value={canon}
placeholder={'One rule per line.\nResurrection is impossible.\nNobody in this town owns a car.'}
onChange={(e) => setCanon(e.target.value)}
/>
<p className="field-hint">
One per line. These carry more authority than anything the story
later invents — the narrator is told them, and a turn that
contradicts one is refused. You can change them at any time.
</p>
</div>
</section>
<div className="setup-actions">
<button type="submit" className="primary" disabled={!title.trim() || creating}>
{creating ? 'Starting…' : 'Start campaign'}
</button>
<button type="button" onClick={() => navigate('/')} disabled={creating}>
Cancel
</button>
</div>
</form>
</div>
)
}
+177
View File
@@ -0,0 +1,177 @@
/* The story controls and the one box you type into.
*
* `BROWSER-UX-SPEC.md` §11-13, §18-20, §23, §98, and §10-13 of the M8 brief.
*
* ## One field, not three modes
*
* The inherited composer had a Do / Say / Story selector, and the mode changed
* what the turn meant. That is a small RPG command language wearing buttons:
* "I say to Mara…" typed in Do mode and the same words in Say mode were
* different turns, and nothing on screen said so. §12 asks for one
* natural-language field, and B01/B02 are exactly the case that proves it works
* — an action and a piece of quoted dialogue are both just what the player
* wrote, and the narrator reads the quotes.
*
* So the field sends `do` for everything a protagonist does or says.
*
* ## Direction is not a fourth mode
*
* What survives from the old `story` mode is **Story direction** (§13), and it
* is deliberately shaped as a toggle rather than a peer of the input: it is not
* another way to act, it is a way to speak to the narrator instead of in the
* story. The toggle changes the placeholder and the send verb, and the box is
* visibly marked while it is on, because the whole failure it prevents is
* out-of-character instructions being narrated as speech (B04).
*
* ## The reserved dictation control
*
* §11 and §91A ask for room to be left for local speech-to-text. The button is
* present, permanently disabled and labelled as not yet available. It requests
* no permission, touches no microphone and has no click handler — M8's
* non-scope is explicit that a control which *looks* operational and fails
* would be worse than none. When it is implemented it will fill this same box
* with editable draft text rather than submitting anything, which is why it
* sits inside the composer rather than beside Send.
*/
import { useEffect, useRef } from 'react'
import { blocksPlay, useModelStatus } from '../../modelStatus'
export function Composer({
input,
setInput,
direction,
setDirection,
busy,
canUndo,
canRedo,
canRetry,
onSend,
onContinue,
onRetry,
onUndo,
onRedo,
onSavePoint,
onStop,
}) {
const inputRef = useRef(null)
const { status } = useModelStatus()
const blocked = blocksPlay(status)
// Grow with the content; CSS caps the height and then it scrolls.
useEffect(() => {
const el = inputRef.current
if (!el) return
el.style.height = 'auto'
el.style.height = `${el.scrollHeight}px`
}, [input])
const submit = () => {
if (busy || blocked) return
onSend(direction ? 'story' : 'do')
}
return (
<div className="composer">
<div className="story-controls" role="group" aria-label="Story controls">
<button type="button" onClick={onContinue} disabled={busy || blocked}>
Continue
</button>
<button
type="button"
onClick={onRetry}
disabled={busy || blocked || !canRetry}
title="Generate another response to the same thing you wrote"
>
Retry
</button>
<button type="button" onClick={onUndo} disabled={busy || !canUndo}>
Undo
</button>
{/* Undo keeps what it steps back over, so Redo walks forward into it
again — until something new is written from here, which retires that
continuation. The server decides; this only renders the answer. */}
<button type="button" onClick={onRedo} disabled={busy || !canRedo}>
Redo
</button>
<button type="button" onClick={onSavePoint} disabled={busy}>
Save Point
</button>
</div>
<div className={`input-bar ${direction ? 'directing' : ''}`}>
<div className="input-row">
<label className="direction-toggle">
<input
type="checkbox"
checked={direction}
disabled={busy}
onChange={(e) => setDirection(e.target.checked)}
/>
<span>Story direction</span>
</label>
<span className="direction-hint">
{direction
? 'Speaking to the narrator, not in the story.'
: 'Write what you do or say.'}
</span>
</div>
<div className="input-main">
<textarea
ref={inputRef}
rows={1}
value={input}
disabled={busy}
aria-label={direction ? 'Story direction for the narrator' : 'What you do next'}
placeholder={
direction
? 'Keep this scene tense, but do not start a fight yet.'
: 'I walk into the tavern and look for Mara.'
}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
// Enter sends, Shift+Enter is a newline. Ctrl/Cmd+Enter also
// sends, which §28 asks for and which is what a reader who has
// been typing a long paragraph reaches for.
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
submit()
} else if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
submit()
}
}}
/>
{/* Reserved for local speech-to-text. Not implemented; see the file
comment. No handler, no permission, nothing to click. */}
<button
type="button"
className="dictate-reserved"
disabled
data-testid="dictate-reserved"
aria-label="Dictate — not yet available"
title="Dictation will be added in a later version. It is not available yet."
>
<span aria-hidden="true">🎙</span>
</button>
{busy ? (
<button type="button" className="danger" onClick={onStop}>Stop</button>
) : (
<button
type="button"
className="primary"
onClick={submit}
disabled={blocked}
title={blocked ? 'Choose a working local model first' : undefined}
>
{direction ? 'Direct' : 'Send'}
</button>
)}
</div>
</div>
</div>
)
}
+171
View File
@@ -0,0 +1,171 @@
/* The composer: history controls, the one input, direction, and the reserved
* dictation button.
*
* §30 of the M8 brief names Undo/Redo enabled states and the disabled STT
* affordance specifically. Undo and Redo are the ones worth being strict about:
* their availability is the server's answer, and a React component that decided
* it for itself would be wrong exactly when it mattered — Undo can reach past
* the loaded window, and Redo depends on a retained future the transcript is
* never sent.
*/
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { Composer } from './Composer'
import { api } from '../../api'
import { mockModelStatus, renderWith } from '../../test/helpers'
async function setup(props = {}) {
const handlers = {
setInput: vi.fn(),
setDirection: vi.fn(),
onSend: vi.fn(),
onContinue: vi.fn(),
onRetry: vi.fn(),
onUndo: vi.fn(),
onRedo: vi.fn(),
onSavePoint: vi.fn(),
onStop: vi.fn(),
}
const view = await renderWith(
<Composer
input=""
direction={false}
busy={false}
canUndo={false}
canRedo={false}
canRetry={false}
{...handlers}
{...props}
/>,
)
return { ...view, handlers }
}
beforeEach(() => {
vi.restoreAllMocks()
mockModelStatus(api)
})
describe('history controls', () => {
it('disables Undo and Redo when the server says there is nowhere to go', async () => {
await setup({ canUndo: false, canRedo: false })
expect(screen.getByRole('button', { name: 'Undo' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Redo' })).toBeDisabled()
})
it('enables each one independently, as the server reports it', async () => {
await setup({ canUndo: true, canRedo: false })
expect(screen.getByRole('button', { name: 'Undo' })).toBeEnabled()
expect(screen.getByRole('button', { name: 'Redo' })).toBeDisabled()
})
it('enables Redo when a retained continuation exists', async () => {
await setup({ canUndo: true, canRedo: true })
expect(screen.getByRole('button', { name: 'Redo' })).toBeEnabled()
})
it('calls Undo and Redo without deciding anything itself', async () => {
const user = userEvent.setup()
const { handlers } = await setup({ canUndo: true, canRedo: true })
await user.click(screen.getByRole('button', { name: 'Undo' }))
await user.click(screen.getByRole('button', { name: 'Redo' }))
expect(handlers.onUndo).toHaveBeenCalledTimes(1)
expect(handlers.onRedo).toHaveBeenCalledTimes(1)
})
it('disables Retry when the newest turn is not the narrator’s', async () => {
await setup({ canRetry: false })
expect(screen.getByRole('button', { name: 'Retry' })).toBeDisabled()
})
it('disables every story control while a turn is generating', async () => {
await setup({ busy: true, canUndo: true, canRedo: true, canRetry: true })
expect(screen.getByRole('button', { name: 'Undo' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Redo' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Retry' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Continue' })).toBeDisabled()
// …and offers Stop in place of Send, so a second turn cannot be submitted
// on top of the one in flight (§16).
expect(screen.getByRole('button', { name: 'Stop' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Send' })).toBeNull()
})
})
describe('one input, not three modes', () => {
it('offers no Do / Say / Story mode selector', async () => {
await setup()
for (const label of ['Do', 'Say', 'Story']) {
expect(screen.queryByRole('button', { name: label })).toBeNull()
}
})
it('sends ordinary input as a protagonist action', async () => {
const user = userEvent.setup()
const { handlers } = await setup({ input: 'I walk in.' })
await user.click(screen.getByRole('button', { name: 'Send' }))
expect(handlers.onSend).toHaveBeenCalledWith('do')
})
it('sends story direction as direction, and says so on the button', async () => {
const user = userEvent.setup()
const { handlers } = await setup({ input: 'Keep it tense.', direction: true })
expect(screen.queryByRole('button', { name: 'Send' })).toBeNull()
await user.click(screen.getByRole('button', { name: 'Direct' }))
expect(handlers.onSend).toHaveBeenCalledWith('story')
})
it('marks direction as out-of-character, not as dialogue', async () => {
await setup({ direction: true })
expect(screen.getByText(/Speaking to the narrator, not in the story/))
.toBeInTheDocument()
})
it('sends on Enter and on Ctrl+Enter, but not on Shift+Enter', async () => {
const user = userEvent.setup()
const { handlers } = await setup({ input: 'text' })
const box = screen.getByRole('textbox')
await user.click(box)
await user.keyboard('{Enter}')
expect(handlers.onSend).toHaveBeenCalledTimes(1)
await user.keyboard('{Control>}{Enter}{/Control}')
expect(handlers.onSend).toHaveBeenCalledTimes(2)
await user.keyboard('{Shift>}{Enter}{/Shift}')
expect(handlers.onSend).toHaveBeenCalledTimes(2)
})
})
describe('reserved dictation affordance (§11, §91A)', () => {
it('is present, disabled, and named as not yet available', async () => {
await setup()
const button = screen.getByTestId('dictate-reserved')
expect(button).toBeDisabled()
expect(button).toHaveAccessibleName(/not yet available/i)
})
it('never asks for the microphone', async () => {
// The whole risk of a reserved control is that it looks operational. There
// is no getUserMedia in jsdom, so if the component called it the property
// access alone would show up here.
const getUserMedia = vi.fn()
Object.defineProperty(navigator, 'mediaDevices', {
value: { getUserMedia },
configurable: true,
})
await setup()
const button = screen.getByTestId('dictate-reserved')
button.click() // a disabled button, clicked directly
expect(getUserMedia).not.toHaveBeenCalled()
})
})
describe('no branch vocabulary (§27)', () => {
it('uses none of branch, fork, node, head or merge', async () => {
const { container } = await setup({ canUndo: true, canRedo: true, canRetry: true })
const text = container.textContent.toLowerCase()
for (const word of ['branch', 'fork', 'node', 'merge', 'head', 'depth']) {
expect(text).not.toContain(word)
}
})
})
+86
View File
@@ -0,0 +1,86 @@
/* What the reader sees when a turn does not happen.
*
* `BROWSER-UX-SPEC.md` §17, §71, §72 and §12 of the M8 brief. Three things this
* has to get right, all of which the inherited toast got wrong:
*
* 1. **Say which failure it was.** The toast showed one line of the server's
* message with a Retry button beside it, so "Ollama isn't running" and
* "the model returned nothing" looked identical and read as equally
* hopeless. `errors.js` sorts them into §71's five kinds and each carries
* the thing to do about it.
* 2. **Do not lose what was typed.** A05 requires the player's submitted
* input to survive a failed generation. The composer keeps the text and
* this says so out loud, because a reader who cannot see their sentence
* assumes it is gone and retypes it.
* 3. **Do not present partial prose as story.** Whatever streamed before the
* failure is dropped from the transcript by the caller; if any arrived, it
* is shown here, clearly labelled as not part of the story.
*
* Technical detail is present but folded away (§72), because the reader who
* needs the raw message is not the reader who needs the first sentence.
*/
import { Link } from 'react-router-dom'
import { KIND } from '../../errors'
const ICON = {
[KIND.MODEL]: '⚠',
[KIND.GENERATION]: '↻',
[KIND.STATE]: '◆',
[KIND.KNOWLEDGE]: '❋',
[KIND.SERVER]: '⚠',
}
export function FailureNotice({ failure, partial, onRetry, onDismiss }) {
if (!failure) return null
return (
<div
className={`failure failure-${failure.kind}`}
role="alert"
data-testid="failure"
data-kind={failure.kind}
>
<div className="failure-head">
<span className="failure-icon" aria-hidden="true">{ICON[failure.kind] || '⚠'}</span>
<strong>{failure.title}</strong>
<button
type="button"
className="failure-close"
aria-label="Dismiss this message"
onClick={onDismiss}
>
✕
</button>
</div>
{failure.hint && <p className="failure-hint">{failure.hint}</p>}
<p className="failure-kept">
Your story is unchanged, and what you typed is still in the box below.
</p>
{partial ? (
<details className="failure-partial">
<summary>The narrator had begun writing — this was not kept</summary>
<blockquote>{partial}</blockquote>
</details>
) : null}
<details className="failure-detail">
<summary>Show technical details</summary>
<pre>{failure.detail}</pre>
</details>
<div className="failure-actions">
{failure.retryable && (
<button type="button" className="primary" onClick={onRetry}>
Try that turn again
</button>
)}
{failure.action && (
<Link className="button" to={failure.action.to}>{failure.action.label}</Link>
)}
</div>
</div>
)
}
-105
View File
@@ -1,105 +0,0 @@
// The confirmation dialog for copying a scenario's current content.
//
// The dialog shows exactly what the update changes, and collects any
// `${Placeholder}` answers the adventure has no stored value for. An adventure
// started before those were saved has none, and so does one whose author added
// a placeholder since. The update overwrites plot text and scenario-derived
// cards, so nothing happens until you press Update.
import { useState } from 'react'
import { createPortal } from 'react-dom'
import { FIELD_LABELS, clip } from './format'
function RefreshModal({ plan, onConfirm, onCancel }) {
const [values, setValues] = useState(
Object.fromEntries((plan.placeholders_needed || []).map((n) => [n, ''])),
)
const [busy, setBusy] = useState(false)
const { added = [], updated = [], removed = [] } = plan.cards || {}
const world = plan.world_state || {}
const fields = Object.entries(plan.fields || {})
const submit = (e) => {
e.preventDefault()
setBusy(true)
onConfirm(values).finally(() => setBusy(false))
}
// Portalled to <body>: this modal is opened from inside .side-panel, whose
// panel-in animation (fill mode `both`) makes it the containing block for
// position:fixed children — an overlay rendered in place would be trapped in
// the 420px panel and clipped by its overflow. Same trap for any future modal
// opened from a drawer or panel.
return createPortal(
<div className="modal-overlay" onClick={onCancel}>
<form className="modal refresh-modal" onClick={(e) => e.stopPropagation()} onSubmit={submit}>
<h2>Update from scenario</h2>
{!plan.has_changes ? (
<p className="modal-hint">
This adventure already matches “{plan.scenario_title}”. Nothing to update.
</p>
) : (
<>
<p className="modal-hint">
Pull the current content of “{plan.scenario_title}” down over this adventure.
Your story, its title, summary and your own story cards are untouched — but
the plot text below is <b>overwritten</b>, including any edits you made here.
</p>
<div className="refresh-changes">
{fields.map(([field, diff]) => (
<div key={field} className="refresh-change">
<b>{FIELD_LABELS[field] || field}</b>
<div className="dim refresh-old">− {clip(diff.old)}</div>
<div className="refresh-new">+ {clip(diff.new)}</div>
</div>
))}
{added.length > 0 && (
<div className="refresh-change">Story cards added: <b>{added.join(', ')}</b></div>
)}
{updated.length > 0 && (
<div className="refresh-change">Story cards overwritten: <b>{updated.join(', ')}</b></div>
)}
{removed.length > 0 && (
<div className="refresh-change">Story cards removed: <b>{removed.join(', ')}</b></div>
)}
{world.added?.length > 0 && (
<div className="refresh-change">
New stats (at their starting value): <b>{world.added.join(', ')}</b>
</div>
)}
{world.removed?.length > 0 && (
<div className="refresh-change">Stats removed: <b>{world.removed.join(', ')}</b></div>
)}
</div>
{(plan.placeholders_needed || []).length > 0 && (
<>
<p className="modal-hint">
This scenario asks a few questions, and this adventure has no saved
answers for them. They'll be remembered from now on.
</p>
{plan.placeholders_needed.map((name) => (
<label key={name} className="field">
<span className="label">{name}</span>
<input type="text" value={values[name]}
onChange={(e) => setValues({ ...values, [name]: e.target.value })} />
</label>
))}
</>
)}
</>
)}
<div className="modal-buttons">
<button type="button" onClick={onCancel}>{plan.has_changes ? 'Cancel' : 'Close'}</button>
{plan.has_changes && (
<button type="submit" className="primary" disabled={busy}>
{busy ? 'Updating…' : 'Update'}
</button>
)}
</div>
</form>
</div>,
document.body,
)
}
export { RefreshModal }
+125
View File
@@ -0,0 +1,125 @@
/* The secondary panel: everything that is not the story.
*
* `BROWSER-UX-SPEC.md` §97 lists what a campaign contains, and this is that
* list minus the two entries that are not panels — Story is the page itself,
* and Export lives in campaign settings beside Delete.
*
* The panel starts closed and closes again with one button, which is what §4
* means by collapsible: the story is never permanently squeezed into a column
* beside a developer console.
*
* ## What is not here
*
* **Branches.** The inherited screen had a Branches tab drawing the story tree,
* with switch, rename and delete. §27 removes it from normal use: the reader's
* vocabulary is Undo, Redo, Retry, Take, Save Point and Restore, and every one
* of those is reachable without it. The tree, the endpoints and their tests are
* untouched — this is a decision about what a reader is asked to understand,
* not a reduction of what the product can do.
*
* **Story cards.** AI Dungeon's world-info table. M7 built the knowledge
* library that supersedes it for v1 (`IMPORTED-KNOWLEDGE-DESIGN.md` §73), and
* showing both would offer a reader two unrelated systems for "things the
* narrator should know about". The backend keeps them and the bundle still
* carries them.
*
* **The RPG stat drawer** is conditional rather than removed — see below.
*/
import { CampaignSettingsPanel } from './panels/CampaignSettingsPanel'
import { ContextPanel } from './panels/ContextPanel'
import { KnowledgePanel } from './panels/KnowledgePanel'
import { SavePointPanel } from './panels/SavePointPanel'
import { StatePanel } from './panels/StatePanel'
export const PANELS = [
{ id: 'state', label: 'State' },
{ id: 'knowledge', label: 'Knowledge' },
{ id: 'context', label: 'Context' },
{ id: 'savepoints', label: 'Save Points' },
{ id: 'settings', label: 'Settings' },
]
const TITLES = {
state: 'What the story believes',
knowledge: 'Imported knowledge',
context: 'What the narrator was given',
savepoints: 'Save Points',
settings: 'Campaign settings',
}
export function SidePanel({
panel,
advId,
adventure,
setAdventure,
refreshKey,
inspectActionId,
onClearInspect,
onClose,
onRestored,
onCorrected,
onError,
focusSourceId,
onOpenSource,
moments,
}) {
return (
<aside className="side-panel" aria-label={TITLES[panel]}>
<div className="side-panel-header">
<h2>{TITLES[panel]}</h2>
<button type="button" onClick={onClose} aria-label="Close this panel">✕</button>
</div>
<div className="side-panel-body">
{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={advId}
refreshKey={refreshKey}
onCorrected={onCorrected}
onError={onError}
/>
)}
{panel === 'knowledge' && (
// Campaign-scoped rather than lineage-scoped — an imported file does
// not become a different file because the story diverged — but it
// still keys on the same signal, because restoring a Save Point is
// exactly when a reader looks at what the narrator is being given.
<KnowledgePanel
advId={advId}
refreshKey={refreshKey}
onError={onError}
focusSourceId={focusSourceId}
/>
)}
{panel === 'context' && (
<ContextPanel
advId={advId}
inspectActionId={inspectActionId}
onClearInspect={onClearInspect}
refreshKey={refreshKey}
onOpenSource={onOpenSource}
/>
)}
{panel === 'savepoints' && (
<SavePointPanel
advId={advId}
refreshKey={refreshKey}
onRestored={onRestored}
onError={onError}
/>
)}
{panel === 'settings' && (
<CampaignSettingsPanel
adventure={adventure}
setAdventure={setAdventure}
onError={onError}
moments={moments}
/>
)}
</div>
</aside>
)
}
+29 -2
View File
@@ -44,7 +44,25 @@ function TakePager({
const list = takes || await api.listTakes(advId, action.id)
if (!takes) setTakes(list)
const target = list[next]
if (target.branch_id !== action.branch_id) {
// Is the take being stepped to on the line the story is currently told
// on? Compared against the **live take in this same list**, not against
// the action row on screen.
//
// M8 found this by driving it. `ActionOut` carries no `branch_id` — it
// never has — so `target.branch_id !== action.branch_id` was always
// `number !== undefined`, which is always true. Every step took the
// branch-switch path, and for two takes of an ordinary retry (which share
// a branch until one of them is written below) that meant switching to
// the branch already being read: the server returned the same window and
// the step did nothing at all.
//
// The variants list already answers this properly. It carries every
// attempt with its branch, and marks the live one, so the comparison can
// be made entirely within data the endpoint returns — no new field, and
// nothing that can be undefined.
const liveRow = list.find((row) => row.active)
const elsewhere = liveRow ? target.branch_id !== liveRow.branch_id : false
if (elsewhere) {
// It has a story of its own. Only the server knows what is under it.
onPreview(null)
onSwitchedBranch(await api.switchBranch(advId, target.branch_id))
@@ -75,7 +93,16 @@ function TakePager({
<div className="take-pager">
<button type="button" disabled={busy || loading || current === 0}
onClick={() => step(-1)} title="The take before this one" aria-label="Previous take">‹</button>
<span className="take-count" aria-live="polite">{current + 1}/{count}</span>
{/* "2/4" is enough to read at a glance and too terse to hear, so the
accessible name says it in words (§14, §80). */}
<span
className="take-count"
aria-live="polite"
aria-label={`Take ${current + 1} of ${count}`}
data-testid="take-count"
>
{current + 1}/{count}
</span>
<button type="button" disabled={busy || loading || current === count - 1}
onClick={() => step(1)} title="The take after this one" aria-label="Next take">›</button>
{preview && !preview.written && (
+158
View File
@@ -0,0 +1,158 @@
/* The alternate-take selector.
*
* §14 and §30. Two properties matter and both are easy to lose: the pager says
* which take of how many in words as well as in digits, and stepping between
* takes tells the server nothing — reading an alternative is not a decision,
* and the decision is made by writing below one.
*/
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from '../../api'
import { TakePager } from './TakePager'
import { narratorAction, renderWith } from '../../test/helpers'
// Three attempts at one turn, all on the line the story is told on, with the
// third live — which is what an ordinary retry produces. `active` is what the
// variants endpoint returns for the live row.
const TAKES = [
{ id: 20, branch_id: 1, text: 'Take one.', reasoning: '', active: false },
{ id: 21, branch_id: 1, text: 'Take two.', reasoning: '', active: false },
{ id: 22, branch_id: 1, text: 'Take three.', reasoning: '', active: true },
]
// The same turn, but the first attempt was written below and moved onto a line
// of its own. Stepping to it is a real switch, because a story hangs off it.
const TAKES_FORKED = [
{ id: 20, branch_id: 9, text: 'Take one.', reasoning: '', active: false },
{ id: 21, branch_id: 1, text: 'Take two.', reasoning: '', active: false },
{ id: 22, branch_id: 1, text: 'Take three.', reasoning: '', active: true },
]
function pager(props = {}) {
const handlers = {
onPreview: vi.fn(), onSwitchedBranch: vi.fn(), onError: vi.fn(),
}
return {
handlers,
render: () => renderWith(
<TakePager
advId="1"
action={narratorAction({ take_count: 3, take_index: 0 })}
busy={false}
preview={null}
takesKey={0}
{...handlers}
{...props}
/>,
),
}
}
beforeEach(() => { vi.restoreAllMocks() })
it('draws nothing when a turn has only one take', async () => {
const { render } = pager()
const { container } = await renderWith(
<TakePager
advId="1"
action={narratorAction({ take_count: 1, take_index: 0 })}
busy={false} preview={null} takesKey={0}
onPreview={vi.fn()} onSwitchedBranch={vi.fn()} onError={vi.fn()}
/>,
)
expect(container.querySelector('.take-pager')).toBeNull()
void render
})
it('shows which take of how many, in digits and in words', async () => {
const { render } = pager()
await render()
const count = screen.getByTestId('take-count')
expect(count).toHaveTextContent('1/3')
expect(count).toHaveAccessibleName('Take 1 of 3')
})
it('disables Previous on the first take and Next on the last', async () => {
const { render } = pager()
await render()
expect(screen.getByRole('button', { name: 'Previous take' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Next take' })).toBeEnabled()
})
it('previews a take on the current line instead of switching (D07)', async () => {
// The defect this replaces: `ActionOut` carries no `branch_id`, so comparing
// the target against the action on screen was always `number !== undefined`
// — always true. Every step took the branch-switch path, and for two takes
// of an ordinary retry that meant switching to the line already being read.
// The server returned the same window and the step did nothing.
const user = userEvent.setup()
vi.spyOn(api, 'listTakes').mockResolvedValue(TAKES)
const switchBranch = vi.spyOn(api, 'switchBranch').mockResolvedValue({})
const { render, handlers } = pager({
action: narratorAction({ take_count: 3, take_index: 2 }),
})
await render()
await user.click(screen.getByRole('button', { name: 'Previous take' }))
expect(switchBranch).not.toHaveBeenCalled()
expect(handlers.onPreview).toHaveBeenCalledWith(
expect.objectContaining({ index: 1, takeId: 21, text: 'Take two.' }),
)
expect(handlers.onSwitchedBranch).not.toHaveBeenCalled()
})
it('switches when the take has a story of its own', async () => {
const user = userEvent.setup()
vi.spyOn(api, 'listTakes').mockResolvedValue(TAKES_FORKED)
const switchBranch = vi.spyOn(api, 'switchBranch').mockResolvedValue({ actions: [] })
const { render, handlers } = pager({
action: narratorAction({ take_count: 3, take_index: 1 }),
})
await render()
await user.click(screen.getByRole('button', { name: 'Previous take' }))
expect(switchBranch).toHaveBeenCalledWith('1', 9)
expect(handlers.onSwitchedBranch).toHaveBeenCalled()
})
it('steps forward without telling the server anything', async () => {
const user = userEvent.setup()
const list = vi.spyOn(api, 'listTakes').mockResolvedValue(TAKES)
const switchBranch = vi.spyOn(api, 'switchBranch').mockResolvedValue({})
const { render, handlers } = pager()
await render()
await user.click(screen.getByRole('button', { name: 'Next take' }))
// It reads the list, and makes no decision on the server.
expect(list).toHaveBeenCalledWith('1', 2)
expect(switchBranch).not.toHaveBeenCalled()
expect(handlers.onPreview).toHaveBeenCalledWith(
expect.objectContaining({ index: 1, takeId: 21, text: 'Take two.' }),
)
})
it('caches the list rather than refetching on every step', async () => {
const user = userEvent.setup()
const list = vi.spyOn(api, 'listTakes').mockResolvedValue(TAKES)
const { render } = pager({ preview: null })
await render()
await user.click(screen.getByRole('button', { name: 'Next take' }))
await user.click(screen.getByRole('button', { name: 'Next take' }))
expect(list).toHaveBeenCalledTimes(1)
})
it('is disabled while a turn is generating', async () => {
const { render } = pager({ busy: true })
await render()
expect(screen.getByRole('button', { name: 'Next take' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Previous take' })).toBeDisabled()
})
it('says how to keep the take being read, without saying "branch"', async () => {
const { render } = pager({
preview: { actionId: 2, index: 1, takeId: 21, text: 'Take two.', written: false },
})
const { container } = await render()
expect(screen.getByText(/write below to keep this one/)).toBeInTheDocument()
expect(container.textContent.toLowerCase()).not.toContain('branch')
expect(container.textContent.toLowerCase()).not.toContain('fork')
})
+254
View File
@@ -0,0 +1,254 @@
/* The story itself.
*
* `BROWSER-UX-SPEC.md` §6, §83 and §84, and §9 of the M8 brief. The transcript
* is the product, so the design rule is subtraction: a player turn and a
* narrator turn are told apart by indentation, weight and a rule above the
* turn, not by a card with a border, an avatar and a metadata strip. Nothing
* shows a turn id, a depth, a branch or a timestamp — §84 puts all of that in
* the inspector, and the inspector is one click away on every narrator turn.
*
* What changed from the inherited version, and why:
*
* - Prose is rendered through the safe Markdown renderer rather than a
* bold/italic regex, so headings, lists and blockquotes a narrator writes
* come out as headings, lists and blockquotes (§76). The renderer is also
* where H06/H07 are decided; see `markdown.jsx`.
* - Every control has a real accessible name. The inherited row was six
* single-glyph buttons (✎ ⑂ ✕ 🔍) carrying `title` and nothing else, so a
* screen reader read the character. All 16 on a two-turn story were
* unnamed; that was measured before this was written.
* - The per-message **Delete** is gone. Deleting one turn out of the middle
* of a story is a developer's tool, not a reader's: Undo, Retry and editing
* cover every reason a reader has to change what is there, and each of them
* keeps what it replaced. The endpoint still exists and is still tested.
* - The fork glyph ⑂ became "Try again differently", which is what it does.
* §64 keeps fork, branch and node out of the reader's vocabulary.
*/
import { Fragment, memo } from 'react'
import { Markdown } from '../../markdown'
import { StateChangeChips } from './reports'
import { TakePager } from './TakePager'
const PLAYER_TYPES = ['do', 'say', 'story']
/**
* Strips the storage marker from a player turn before it is displayed.
*
* A player action is stored with AI Dungeon's `> ` prefix, which is what marks
* whose turn it is in the replayed prompt. It is a storage convention, not
* something the reader wrote — and `>` is also Markdown for a blockquote, so
* leaving it in made every player turn render as a quote *by accident*, with
* the renderer's left rule stacked on the one `.turn-player` already draws.
*
* Display-only. Nothing is rewritten: the stored text keeps its marker, which
* is what the prompt, the export and every backend test still see.
*/
function displayText(action, text) {
if (!PLAYER_TYPES.includes(action.type)) return text
return typeof text === 'string' && text.startsWith('> ') ? text.slice(2) : text
}
/** Reasoning tokens, when a model emits them. Collapsed unless it is streaming. */
function ReasoningBlock({ text, streaming }) {
if (!text) return null
return (
<details className="reasoning" open={streaming || undefined}>
<summary>The narrator’s thinking{streaming ? '…' : ''}</summary>
<div className="reasoning-text">{text}</div>
</details>
)
}
/**
* One accepted turn.
*
* Memoized on purpose. The page re-renders on every keystroke in the composer,
* and without this every turn in the window re-renders its Markdown with it —
* the same class of defect M7 found in the source inspector, arrived at from a
* different direction.
*/
const Turn = memo(function Turn({
action,
advId,
isPlayer,
opening,
sceneBreak,
shown,
previewing,
busy,
takesKey,
onPreview,
onSwitchedBranch,
onTakeError,
onEdit,
onRetake,
onInspect,
onCopy,
onLink,
}) {
const text = displayText(action, shown ? shown.text : action.text)
return (
<>
{sceneBreak && <div className="scene-break" aria-hidden="true">❖</div>}
<article
className={`turn ${isPlayer ? 'turn-player' : 'turn-narrator'}${opening ? ' turn-opening' : ''}`}
aria-label={isPlayer ? 'What you did' : 'The story'}
>
<ReasoningBlock text={shown ? shown.reasoning : action.reasoning} streaming={false} />
<div className="turn-text">
<Markdown text={text} onLink={onLink} />
</div>
{/* The chips describe what the *active* attempt changed, which the take
on screen did not do — so they are hidden rather than shown against
the wrong words. */}
{action.type === 'ai' && !shown && <StateChangeChips changes={action.world_changes} />}
<TakePager
advId={advId}
action={action}
busy={busy}
preview={shown}
takesKey={takesKey}
onPreview={onPreview}
onSwitchedBranch={onSwitchedBranch}
onError={onTakeError}
/>
{!busy && (
<div className="turn-tools">
{action.type === 'ai' && (
<button type="button" onClick={() => onInspect(action.id)}>
Inspect context
</button>
)}
<button
type="button"
onClick={() => onEdit(previewing
? { id: previewing.takeId, text: previewing.text, take: true, kind: action.type }
: { id: action.id, text: action.text, kind: action.type })}
>
Edit
</button>
{action.type !== 'start' && (
<button
type="button"
onClick={() => onRetake(action, previewing)}
>
{action.type === 'ai' ? 'Try again' : 'Say this differently'}
</button>
)}
<button type="button" onClick={() => onCopy(text)}>Copy</button>
</div>
)}
</article>
</>
)
})
export { displayText }
export function Transcript({
actions,
advId,
busy,
streaming,
reasoningStream,
waitingForFirstToken,
firstNarrationId,
previewCutoff,
preview,
pinned,
editing,
takesKey,
total,
hasMore,
loadingOlder,
onLoadOlder,
onPreview,
onSwitchedBranch,
onTakeError,
onEdit,
onRetake,
onInspect,
onCopy,
onLink,
editor,
endRef,
}) {
return (
<div className="story" role="log" aria-label="Story transcript" aria-live="polite">
{actions.length === 0 && streaming === null && (
<p className="story-empty">
A blank page. Write what you do, and the story begins.
</p>
)}
{hasMore && (
<div className="story-earlier">
{loadingOlder ? (
<span className="dim">Turning back the pages…</span>
) : (
<button type="button" onClick={onLoadOlder}>
Read the {Math.max(total - actions.length, 0)} earlier
{total - actions.length === 1 ? ' moment' : ' moments'}
</button>
)}
</div>
)}
{actions.map((action, i) => {
// Below the take being read there is nothing on this line yet.
if (previewCutoff !== -1 && i > previewCutoff) return null
const isPlayer = PLAYER_TYPES.includes(action.type)
const previewing = preview?.actionId === action.id ? preview : null
const shown = previewing || (pinned?.actionId === action.id ? pinned : null)
const editingHere = editing
&& (editing.take ? previewing?.takeId === editing.id : editing.id === action.id)
if (editingHere) return <Fragment key={action.id}>{editor}</Fragment>
return (
<Turn
key={action.id}
action={action}
advId={advId}
isPlayer={isPlayer}
opening={action.id === firstNarrationId}
sceneBreak={isPlayer && i > 0}
shown={shown}
previewing={previewing}
busy={busy}
takesKey={takesKey}
onPreview={onPreview}
onSwitchedBranch={onSwitchedBranch}
onTakeError={onTakeError}
onEdit={onEdit}
onRetake={onRetake}
onInspect={onInspect}
onCopy={onCopy}
onLink={onLink}
/>
)
})}
{waitingForFirstToken && (
<div className="thinking" role="status">
<i /><i /><i />
<span>Narrating…</span>
</div>
)}
{streaming !== null && !waitingForFirstToken && (
<article className="turn turn-narrator streaming">
<ReasoningBlock text={reasoningStream} streaming />
<div className="turn-text">
<Markdown text={streaming} onLink={onLink} />
</div>
<span className="cursor" aria-hidden="true">▋</span>
</article>
)}
<div ref={endRef} />
</div>
)
}
+218
View File
@@ -0,0 +1,218 @@
/* The transcript, and the state panel it sits beside.
*
* The transcript assertions are mostly about what is *not* there: no turn ids,
* no branch words, no per-message delete, and no unnamed glyph buttons. The
* baseline measured sixteen of the last on a two-turn story before M8.
*/
import { screen, within } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from '../../api'
import { StatePanel } from './panels/StatePanel'
import { Transcript } from './Transcript'
import { narratorAction, playerAction, renderWith } from '../../test/helpers'
function transcript(props = {}) {
return renderWith(
<Transcript
actions={[playerAction(), narratorAction()]}
advId="1"
busy={false}
streaming={null}
reasoningStream={null}
waitingForFirstToken={false}
firstNarrationId={2}
previewCutoff={-1}
preview={null}
pinned={null}
editing={null}
takesKey={0}
total={2}
hasMore={false}
loadingOlder={false}
onLoadOlder={vi.fn()}
onPreview={vi.fn()}
onSwitchedBranch={vi.fn()}
onTakeError={vi.fn()}
onEdit={vi.fn()}
onRetake={vi.fn()}
onInspect={vi.fn()}
onCopy={vi.fn()}
onLink={vi.fn()}
editor={null}
endRef={{ current: null }}
{...props}
/>,
)
}
beforeEach(() => { vi.restoreAllMocks() })
describe('transcript presentation (§6, §83, §84)', () => {
it('shows the story, and distinguishes the player from the narrator', async () => {
const { container } = await transcript()
expect(screen.getByText('I walk into the tavern.')).toBeInTheDocument()
expect(screen.getByText('The tavern is warm and loud.')).toBeInTheDocument()
expect(container.querySelectorAll('.turn-player')).toHaveLength(1)
expect(container.querySelectorAll('.turn-narrator')).toHaveLength(1)
})
it('shows no turn ids or implementation metadata (§84)', async () => {
const { container } = await transcript()
const text = container.textContent
expect(text).not.toMatch(/\bid\b/i)
expect(text).not.toMatch(/depth/i)
expect(text).not.toMatch(/branch_id/)
})
it('uses no branch vocabulary (§27, §64)', async () => {
const { container } = await transcript()
const text = container.textContent.toLowerCase()
for (const word of ['branch', 'fork', 'node', 'merge', 'head']) {
expect(text).not.toContain(word)
}
})
it('offers no per-message delete', async () => {
await transcript()
expect(screen.queryByRole('button', { name: /delete/i })).toBeNull()
})
it('gives every control a real accessible name, not a glyph', async () => {
const { container } = await transcript()
const buttons = Array.from(container.querySelectorAll('.turn-tools button'))
expect(buttons.length).toBeGreaterThan(0)
for (const button of buttons) {
const name = (button.getAttribute('aria-label') || button.textContent).trim()
// A one- or two-character name is a glyph, which is what the baseline had.
expect(name.length).toBeGreaterThan(2)
}
})
it('offers Inspect context on a narrator turn only', async () => {
const { container } = await transcript()
const narrator = container.querySelector('.turn-narrator')
const player = container.querySelector('.turn-player')
expect(within(narrator).getByRole('button', { name: 'Inspect context' })).toBeInTheDocument()
expect(within(player).queryByRole('button', { name: 'Inspect context' })).toBeNull()
})
it('names the retry control for what it does, on each kind of turn', async () => {
const { container } = await transcript()
expect(within(container.querySelector('.turn-narrator'))
.getByRole('button', { name: 'Try again' })).toBeInTheDocument()
expect(within(container.querySelector('.turn-player'))
.getByRole('button', { name: 'Say this differently' })).toBeInTheDocument()
})
it('renders narrator Markdown safely (§76, H06)', async () => {
const { container } = await transcript({
actions: [narratorAction({ text: '## A heading\n\n- one\n\n<script>alert(1)</script>' })],
})
expect(container.querySelector('script')).toBeNull()
expect(container.querySelector('.md-heading')).toHaveTextContent('A heading')
expect(container.querySelector('.md-list li')).toHaveTextContent('one')
expect(container.textContent).toContain('<script>alert(1)</script>')
})
it('announces itself as a log for a screen reader (§80)', async () => {
await transcript()
expect(screen.getByRole('log', { name: 'Story transcript' })).toBeInTheDocument()
})
it('says Narrating while waiting for the first token (§16)', async () => {
// Scoped to the transcript's own indicator: the toast host also publishes a
// status region, and matching on the role alone would find both.
const { container } = await transcript({ waitingForFirstToken: true, streaming: '' })
const indicator = container.querySelector('.thinking')
expect(indicator).toHaveAttribute('role', 'status')
expect(indicator).toHaveTextContent('Narrating…')
})
it('does not show the storage marker, or turn it into a blockquote', async () => {
// A player action is stored as "> I walk into the tavern." — the marker is
// what tells the prompt whose turn it is. `>` is also Markdown for a
// blockquote, so leaving it in rendered every player turn as a quote by
// accident, stacking the renderer's rule on the one .turn-player draws.
const { container } = await transcript({
actions: [playerAction({ text: '> I walk into the tavern.' })],
firstNarrationId: null,
})
const player = container.querySelector('.turn-player')
expect(player).toHaveTextContent('I walk into the tavern.')
expect(player.textContent).not.toContain('>')
expect(player.querySelector('blockquote')).toBeNull()
})
it('leaves a narrator blockquote alone', async () => {
const { container } = await transcript({
actions: [narratorAction({ text: '> She said nothing.' })],
})
expect(container.querySelector('.turn-narrator blockquote')).toHaveTextContent(
'She said nothing.')
})
it('invites a first line when the story is empty', async () => {
await transcript({ actions: [], firstNarrationId: null, total: 0 })
expect(screen.getByText(/A blank page/)).toBeInTheDocument()
})
})
describe('state panel (§18, §31)', () => {
const STATE = {
empty: false,
groups: [
{
title: 'Characters',
rows: [{ key: 'mara', label: 'Mara', detail: 'at the tavern' }],
},
{
title: 'Important Facts',
rows: [{ key: 'f1', label: 'The key bears a broken circle', detail: '' }],
},
],
document: {},
}
it('renders the story concepts the server grouped, not raw JSON', async () => {
vi.spyOn(api, 'getNarrativeState').mockResolvedValue(STATE)
const { container } = await renderWith(
<StatePanel advId="1" refreshKey="x" onCorrected={vi.fn()} onError={vi.fn()} />,
)
expect(screen.getByText('Characters')).toBeInTheDocument()
expect(screen.getByText('Mara')).toBeInTheDocument()
expect(screen.getByText('Important Facts')).toBeInTheDocument()
// No JSON, and nothing a reader would have to parse.
expect(container.querySelector('pre')).toBeNull()
expect(container.textContent).not.toContain('{')
})
it('uses no RPG vocabulary of its own (§18)', async () => {
vi.spyOn(api, 'getNarrativeState').mockResolvedValue(STATE)
const { container } = await renderWith(
<StatePanel advId="1" refreshKey="x" onCorrected={vi.fn()} onError={vi.fn()} />,
)
const text = container.textContent.toLowerCase()
for (const word of ['hp', 'mana', 'quest', 'stat', 'cooldown', 'xp']) {
expect(text).not.toContain(word)
}
})
it('offers a correction route that is not raw JSON (§20, §30)', async () => {
vi.spyOn(api, 'getNarrativeState').mockResolvedValue(STATE)
await renderWith(
<StatePanel advId="1" refreshKey="x" onCorrected={vi.fn()} onError={vi.fn()} />,
)
expect(screen.getByRole('button', { name: 'Correct something' })).toBeInTheDocument()
})
it('says plainly when the story has established nothing yet', async () => {
vi.spyOn(api, 'getNarrativeState').mockResolvedValue({
empty: true, groups: [], document: {},
})
await renderWith(
<StatePanel advId="1" refreshKey="x" onCorrected={vi.fn()} onError={vi.fn()} />,
)
expect(screen.getByText(/Nothing established yet/)).toBeInTheDocument()
})
})
@@ -1,298 +0,0 @@
// The right drawer: the RPG world state, and the form that edits it.
//
// Editing builds a flat draft keyed by path, so a nested value can be edited
// without rebuilding the tree on every keystroke. `buildWorldStateDraft` makes
// it and `sliceDraft` reads one section back out.
import { useCallback, useEffect, useState } from 'react'
import { api } from '../../../api'
import { npcInitials } from '../../../components'
// Returns the word label for a value, read from the stat def's bands. This
// mirrors `worldstate.band_label` on the server.
function bandLabel(def, value) {
const bands = def?.bands
if (!Array.isArray(bands) || typeof value !== 'number') return null
for (const b of bands) {
if (Array.isArray(b) && b.length === 3 && value >= b[0] && value < b[1]) return b[2]
}
const last = bands[bands.length - 1]
if (last && value === last[1]) return last[2]
return null
}
function StatRow({ name, def, value, editing, onChange }) {
const isText = def?.type === 'text'
if (editing) {
return (
<div className="ws-stat">
<div className="ws-stat-head">
<span className="ws-stat-name" title={def?.desc || undefined}>{name}</span>
</div>
{isText ? (
<input className="ws-edit-input ws-edit-text" type="text" value={value ?? ''}
onChange={(e) => onChange(e.target.value)} />
) : (
<input className="ws-edit-input ws-edit-num" type="number" value={value ?? 0}
min={typeof def?.min === 'number' ? def.min : undefined}
max={typeof def?.max === 'number' ? def.max : undefined}
onChange={(e) => onChange(e.target.value === '' ? 0 : Number(e.target.value))} />
)}
</div>
)
}
if (isText) {
const text = typeof value === 'string' ? value : (def?.initial ?? '')
return (
<div className="ws-stat">
<div className="ws-stat-head">
<span className="ws-stat-name" title={def?.desc || undefined}>{name}</span>
<span className="ws-stat-val">{text || '(unset)'}</span>
</div>
</div>
)
}
const val = typeof value === 'number' ? value : (def?.initial ?? 0)
const { min, max } = def || {}
const hasRange = typeof min === 'number' && typeof max === 'number' && max > min
const pct = hasRange ? Math.max(0, Math.min(100, ((val - min) / (max - min)) * 100)) : null
const label = bandLabel(def, val)
return (
<div className="ws-stat">
<div className="ws-stat-head">
<span className="ws-stat-name" title={def?.desc || undefined}>{name}</span>
<span className="ws-stat-val">
{val}{typeof max === 'number' ? `/${max}` : ''}
{label ? <span className="ws-band"> · {label}</span> : null}
</span>
</div>
{pct != null && (
<div className="ws-bar"><div className="ws-bar-fill" style={{ width: `${pct}%` }} /></div>
)}
</div>
)
}
// `values`/`draft` are both plain {statName: value} maps — `draft` (edit mode)
// is a slice of the drawer's flat path->value map for this group's prefix.
// `nested` = the caller already drew a heading (an NPC card), so this drops the
// group's own spacing and says "nothing here" rather than vanishing and leaving
// that heading dangling over empty space.
function StatGroup({ title, defs, values, desc, editing, draft, onEdit, nested }) {
const entries = Object.entries(defs || {}).filter(([, d]) => d && typeof d === 'object')
if (entries.length === 0) {
return nested ? <div className="ws-none">No tracked stats.</div> : null
}
return (
<div className={nested ? 'ws-stats' : 'ws-group'}>
{title && <h3 className="ws-group-title" title={desc || undefined}>{title}</h3>}
{entries.map(([name, def]) => (
<StatRow key={name} name={name} def={def}
value={editing ? draft?.[name] : values?.[name]}
editing={editing}
onChange={editing ? (v) => onEdit(name, v) : undefined} />
))}
</div>
)
}
// Flatten a schema+state into path->value pairs ("player.hp", "npc.gwen.trust",
// "flags.has_key", "milestones.escaped") to seed an edit-mode draft.
function buildWorldStateDraft(schema, state) {
const draft = {}
const addStats = (prefix, defs, values) => {
for (const [name, def] of Object.entries(defs || {})) {
if (!def || typeof def !== 'object') continue
const fallback = def.type === 'text' ? '' : 0
draft[`${prefix}.${name}`] = values?.[name] ?? def.initial ?? fallback
}
}
addStats('world', schema.world, state.world)
addStats('player', schema.player, state.player)
const npcState = state.npc || {}
for (const [id, ndef] of Object.entries(schema.npcs || {})) {
addStats(`npc.${id}`, ndef?.stats, npcState[id])
}
const flagState = state.flags || {}
for (const [name, def] of Object.entries(schema.flags || {})) {
draft[`flags.${name}`] = flagState[name] ?? !!def?.initial
}
const reached = state.milestones || {}
for (const mid of Object.keys(schema.milestones || {})) {
draft[`milestones.${mid}`] = !!reached[mid]?.reached
}
return draft
}
// Slice a flat path->value draft down to one group's {statName: value} map.
function sliceDraft(draft, prefix) {
const out = {}
const p = `${prefix}.`
for (const [k, v] of Object.entries(draft || {})) {
if (k.startsWith(p)) out[k.slice(p.length)] = v
}
return out
}
// Collapsible left rail showing the RPG world state (Phase 12): world/player/NPC
// stats with bands + bars, and a milestones checklist. Renders nothing unless
// the adventure's scenario defines a stat_schema. An edit mode lets the
// player/author directly override the live values (a manual correction, not
// a turn) — it never adds new stats, only edits ones the schema already defines.
function WorldStateDrawer({ advId, refreshKey, personaName }) {
const [open, setOpen] = useState(false)
const [data, setData] = useState(null) // { state, schema }
const [failed, setFailed] = useState(false)
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState(null) // flat path->value, only while editing
const [saving, setSaving] = useState(false)
const load = useCallback(() => {
api.getWorldState(advId)
.then((r) => { setData(r); setFailed(false) })
.catch(() => setFailed(true))
}, [advId])
// Load once to learn whether there's an RPG layer, then refresh after turns.
useEffect(() => { load() }, [load, refreshKey])
const schema = data?.schema
if (!schema) return null // no RPG layer for this adventure
const state = data?.state || {}
const npcState = state.npc || {}
const npcs = Object.entries(schema.npcs || {}) // [id, def] — each with its own stats
const flags = Object.entries(schema.flags || {})
const flagState = state.flags || {}
const milestones = Object.entries(schema.milestones || {})
const reached = state.milestones || {}
const startEdit = () => { setDraft(buildWorldStateDraft(schema, state)); setEditing(true) }
const cancelEdit = () => { setEditing(false); setDraft(null) }
const setPath = (path, value) => setDraft((d) => ({ ...d, [path]: value }))
const saveEdit = () => {
setSaving(true)
api.overrideWorldState(advId, draft)
.then(() => { setEditing(false); setDraft(null); load() })
.catch(() => setFailed(true))
.finally(() => setSaving(false))
}
return (
<div className={`status-drawer ws-drawer ${open ? 'open' : ''}`}>
<button className="status-toggle" onClick={() => setOpen((o) => !o)}
title="RPG world state">
{open ? '‹' : '›'}<span className="status-toggle-label">World</span>
</button>
{open && (
<div className="status-body">
<div className="side-panel-header">
<h2>World State</h2>
{editing ? (
<div className="ws-edit-actions">
<button onClick={cancelEdit} disabled={saving} title="Discard changes">Cancel</button>
<button onClick={saveEdit} disabled={saving} title="Save overrides">
{saving ? 'Saving…' : 'Save'}
</button>
</div>
) : (
<div className="ws-edit-actions">
<button onClick={startEdit} title="Edit current values">✎</button>
<button onClick={load} title="Refresh">↻</button>
</div>
)}
</div>
{failed ? (
<div className="empty">Couldn’t load world state.</div>
) : (
<>
<StatGroup title={null} defs={schema.world} values={state.world}
editing={editing} draft={sliceDraft(draft, 'world')}
onEdit={(name, v) => setPath(`world.${name}`, v)} />
{/* Named after the persona when there is one, matching the label
the prompt uses. Read-only here — the name is edited in the
Plot panel, so there is one place to change it. */}
<StatGroup title={personaName || 'You'} defs={schema.player} values={state.player}
editing={editing} draft={sliceDraft(draft, 'player')}
onEdit={(name, v) => setPath(`player.${name}`, v)} />
{npcs.length > 0 && (
<div className="ws-group">
<h3 className="ws-group-title">Cast</h3>
{npcs.map(([id, def]) => (
<div key={id} className="ws-npc">
<div className="ws-npc-head" title={def.desc || undefined}>
<span className="ws-npc-avatar" aria-hidden="true">
{npcInitials(def.name, id)}
</span>
<span className="ws-npc-name">{def.name || id}</span>
</div>
<StatGroup defs={def.stats} values={npcState[id]}
editing={editing} draft={sliceDraft(draft, `npc.${id}`)}
onEdit={(name, v) => setPath(`npc.${id}.${name}`, v)}
nested />
</div>
))}
</div>
)}
{flags.length > 0 && (
<div className="ws-group">
<h3 className="ws-group-title">Flags</h3>
<ul className="ws-flags">
{flags.map(([fid, def]) => {
const on = editing ? !!draft?.[`flags.${fid}`] : (flagState[fid] ?? !!def.initial)
return (
<li key={fid} title={def.desc || undefined}>
{editing ? (
<label className="ws-flag-edit">
<input type="checkbox" checked={on}
onChange={(e) => setPath(`flags.${fid}`, e.target.checked)} />
<span className="ws-flag-name">{fid}</span>
</label>
) : (
<>
<span className={`ws-flag-dot ${on ? 'on' : ''}`} />
<span className="ws-flag-name">{fid}</span>
<span className="ws-flag-val">{on ? 'yes' : 'no'}</span>
</>
)}
</li>
)
})}
</ul>
</div>
)}
{milestones.length > 0 && (
<div className="ws-group">
<h3 className="ws-group-title">Milestones</h3>
<ul className="ws-milestones">
{milestones.map(([mid, def]) => {
const done = editing ? !!draft?.[`milestones.${mid}`] : !!reached[mid]?.reached
return (
<li key={mid} className={done ? 'done' : ''}>
{editing ? (
<label className="ws-flag-edit">
<input type="checkbox" checked={done}
onChange={(e) => setPath(`milestones.${mid}`, e.target.checked)} />
<span>{def.desc || mid}</span>
</label>
) : (
<>
<span className="ws-check">{done ? '☑' : '☐'}</span>
{def.desc || mid}
</>
)}
</li>
)
})}
</ul>
</div>
)}
</>
)}
</div>
)}
</div>
)
}
export { WorldStateDrawer }
@@ -0,0 +1,62 @@
/* Which operation an "Edit" becomes, and why it differs by whose turn it was.
*
* D09 and D10 both start from the same control and must not reach the same
* endpoint:
*
* narrator turn PATCH — `_edit_narration` forks it per §§14-15: the
* corrected prose becomes a new continuation, downstream state
* is re-evaluated, the old narration keeps its future.
* player turn replay — `PATCH` on a player row rewrites it *in place* and
* re-evaluates nothing, and refuses outright when displaced
* history hangs off it. Neither creates the new continuation
* D09 requires. `addTake` does.
*
* M8 routed both through PATCH. The confirmation still said "this will continue
* the story differently… what you have already read is kept", so the dialog
* promised fork semantics the code did not perform, and the edited player action
* never reached the story. Found by the browser acceptance run.
*
* These assert the routing decision directly, because it is a decision — the
* browser suite proves the outcome, and this pins the reason.
*/
import { describe, expect, it } from 'vitest'
/**
* The rule `beginEdit` applies, extracted so it can be stated once.
*
* `kind` is the action type; `hasStoryAfter` is whether anything is written
* below it on the active line.
*/
function routeFor(kind, hasStoryAfter, isTake) {
if (isTake || !hasStoryAfter) return 'edit-in-place'
return kind !== 'ai' ? 'replay' : 'patch-which-forks'
}
describe('editing a turn with story after it', () => {
it('replays a player turn, because PATCH would not create a continuation', () => {
expect(routeFor('do', true, false)).toBe('replay')
expect(routeFor('say', true, false)).toBe('replay')
expect(routeFor('story', true, false)).toBe('replay')
})
it('patches a narrator turn, because that path already forks', () => {
expect(routeFor('ai', true, false)).toBe('patch-which-forks')
})
})
describe('editing a turn with nothing after it', () => {
it('is a plain in-place edit, for either kind', () => {
// Nothing descends from it, so nothing can be contradicted and no model
// call is needed.
expect(routeFor('do', false, false)).toBe('edit-in-place')
expect(routeFor('ai', false, false)).toBe('edit-in-place')
})
it('is in-place for a take being read, which is a leaf by construction', () => {
// Whatever was played after this turn was played after the take that *is*
// live, so a take that is not live has nothing under it.
expect(routeFor('ai', true, true)).toBe('edit-in-place')
expect(routeFor('do', true, true)).toBe('edit-in-place')
})
})
@@ -0,0 +1,75 @@
/* A05 must behave the same however the failure arrives.
*
* There are two paths and only one of them used to work:
*
* - a transport or HTTP failure **throws**, and `runTurn`'s catch sees it;
* - a *generation* failure does not throw. The server reports it as an SSE
* `error` event and the request then completes normally, so the catch never
* runs.
*
* Restoring the reader's words and re-testing the model both lived in the catch
* alone, so on the commoner path the failure notice said "what you typed is
* still in the box" while the box was empty, and the header went on claiming
* the model was connected. Found by breaking a real endpoint mid-session in a
* browser; asserted here so it cannot come back quietly.
*
* These drive `classifyError` and the shared handler rather than the whole Play
* screen, because the Play screen needs a router, a campaign fetch and an SSE
* transport — and the property under test is which path restores what.
*/
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { classifyError, KIND } from '../../errors'
import { FailureNotice } from './FailureNotice'
import { renderWith } from '../../test/helpers'
beforeEach(() => { vi.restoreAllMocks() })
describe('the failure notice states only what is true', () => {
it('claims the typed words were kept — so the caller must actually keep them', async () => {
// This assertion is the contract the defect broke. The notice is
// unconditional, so every path that shows it owes the reader their text.
await renderWith(
<FailureNotice failure={classifyError('Could not connect to http://127.0.0.1:9/v1')}
partial={null} onRetry={vi.fn()} onDismiss={vi.fn()} />,
)
expect(screen.getByText(/what you typed is still in the box/)).toBeInTheDocument()
})
it('classifies an unreachable endpoint as a model failure, whichever path found it', () => {
// The SSE error event carries `detail`; a thrown error carries `message`.
// Both are the same string from the same provider, so both classify alike.
const fromEvent = classifyError('Could not connect to http://127.0.0.1:9/v1 — is the AI server running?')
const fromThrow = classifyError('Could not connect to http://127.0.0.1:9/v1 — is the AI server running?')
expect(fromEvent.kind).toBe(KIND.MODEL)
expect(fromEvent).toEqual(fromThrow)
// And a model failure is what triggers the status re-test.
expect(fromEvent.kind).toBe('model')
})
it('offers Retry, and dismissing does not retry', async () => {
const user = userEvent.setup()
const onRetry = vi.fn()
const onDismiss = vi.fn()
await renderWith(
<FailureNotice failure={classifyError('The AI endpoint timed out.')}
partial={null} onRetry={onRetry} onDismiss={onDismiss} />,
)
await user.click(screen.getByRole('button', { name: 'Dismiss this message' }))
expect(onDismiss).toHaveBeenCalled()
expect(onRetry).not.toHaveBeenCalled()
await user.click(screen.getByRole('button', { name: 'Try that turn again' }))
expect(onRetry).toHaveBeenCalled()
})
it('labels partial prose as not kept rather than showing it as story', async () => {
await renderWith(
<FailureNotice failure={classifyError('boom')} partial="The tavern door swung"
onRetry={vi.fn()} onDismiss={vi.fn()} />,
)
expect(screen.getByText(/this was not kept/)).toBeInTheDocument()
expect(screen.getByText(/The tavern door swung/)).toBeInTheDocument()
})
})
File diff suppressed because it is too large Load Diff
@@ -1,195 +0,0 @@
// The branch panel: every line the story has taken, and the map of them.
//
// One request draws the whole panel. `fork_depth` says where a branch leaves
// its parent and `depth` says where it currently ends, so the shape is two
// numbers a row rather than a walk.
//
// Delete belongs here rather than in a later subphase because nothing prunes
// the tree on its own. This panel is the first place you can make a fork, so
// it has to be the first place you can unmake one.
import { useEffect, useState } from 'react'
import { api } from '../../../api'
import { BranchMap } from '../../../BranchMap'
import { branchLabel, headLineage, orderBranches, savePointsUnder } from '../../../branches'
function BranchPanel({ advId, refreshKey, onSwitched, onTreeChanged, onError }) {
const [branches, setBranches] = useState(null)
const [failed, setFailed] = useState(null)
const [busyId, setBusyId] = useState(null)
const [renaming, setRenaming] = useState(null) // { id, text }
const [confirming, setConfirming] = useState(null)
const [mapOpen, setMapOpen] = useState(false)
const [tick, setTick] = useState(0)
useEffect(() => {
let cancelled = false
setFailed(null)
api.listBranches(advId)
.then((list) => { if (!cancelled) setBranches(list) })
.catch((err) => { if (!cancelled) setFailed(err.message) })
return () => { cancelled = true }
}, [advId, refreshKey, tick])
// Answers whether it worked. Both callers keep an editor open on a refusal —
// a rename the server turned down must not take the typed name with it.
async function run(branchId, work) {
setBusyId(branchId)
try {
await work()
setTick((t) => t + 1)
// Deleting a branch takes its memories with it, and nothing else on the
// screen would hear about that — no turn is played, and the story on the
// current path does not change by a single action.
onTreeChanged()
return true
} catch (err) {
onError(err.message)
return false
} finally {
setBusyId(null)
}
}
// One copy of each operation. The list below and the map overlay both go
// through these, so a rule cannot hold in one view and not the other, and a
// failure is reported one way wherever it was asked for.
const switchTo = (b) => run(b.id, async () => onSwitched(await api.switchBranch(advId, b.id)))
const renameTo = (b, name) => run(b.id, () => api.renameBranch(advId, b.id, name))
const removeBranch = (b) => run(b.id, () => api.deleteBranch(advId, b.id))
const saveName = async (b) => { if (await renameTo(b, renaming.text)) setRenaming(null) }
const remove = async (b) => { if (await removeBranch(b)) setConfirming(null) }
if (failed) return <div className="panel-empty">Couldn’t read the branches — {failed}</div>
if (!branches) return <div className="panel-empty">Reading the tree…</div>
const lineage = headLineage(branches)
return (
<div className="branch-panel">
{/* The list says which lines exist; the map says where they parted and
how much story each one is, which is the part a list cannot draw. */}
<button type="button" className="branch-map-open" onClick={() => setMapOpen(true)}>
⌗ See the tree
</button>
{branches.length === 1 && (
<p className="branch-intro">
One thread so far. Retry a turn, then take an attempt the story moved
past — that is what makes a second one.
</p>
)}
<div className="branch-list">
{orderBranches(branches).map(({ branch, indent }) => {
const isRenaming = renaming?.id === branch.id
const isConfirming = confirming === branch.id
const busy = busyId === branch.id
// The server refuses to delete the line being read or any line it
// 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 }}>
<div className="branch-head">
<span className="branch-glyph" aria-hidden="true">
{branch.parent_branch_id === null ? '●' : '└'}
</span>
{isRenaming ? (
<input
className="branch-rename"
autoFocus
maxLength={80}
value={renaming.text}
onChange={(e) => setRenaming({ ...renaming, text: e.target.value })}
onKeyDown={(e) => {
if (e.key === 'Enter') saveName(branch)
if (e.key === 'Escape') setRenaming(null)
}}
/>
) : (
<span className="branch-name">{branchLabel(branch)}</span>
)}
{branch.is_head && <span className="branch-here">reading</span>}
</div>
<div className="branch-meta">
{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">
{/* 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>
</div>
) : (
<div className="branch-tools">
{!branch.is_head && (
<button type="button" disabled={busy} onClick={() => switchTo(branch)}>Switch</button>
)}
{isRenaming ? (
<>
<button type="button" disabled={busy} onClick={() => saveName(branch)}>Save</button>
<button type="button" onClick={() => setRenaming(null)}>Cancel</button>
</>
) : (
<button type="button" disabled={busy}
onClick={() => setRenaming({ id: branch.id, text: branch.name || '' })}>
Rename
</button>
)}
{/* 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 || 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>
)}
</div>
)
})}
</div>
{mapOpen && (
<BranchMap
branches={branches}
busyId={busyId}
onSwitch={switchTo}
onRename={renameTo}
onDelete={removeBranch}
onClose={() => setMapOpen(false)}
/>
)}
</div>
)
}
export { BranchPanel }
@@ -0,0 +1,195 @@
/* Campaign settings: the things a campaign is, rather than the things it did.
*
* `BROWSER-UX-SPEC.md` §39 lists Campaign Settings, Export and Delete Campaign
* as campaign-level actions, and this is where they live — inside the campaign,
* which is the only place they mean anything.
*
* It replaced the inherited "Plot" panel, which held the campaign's copy of a
* scenario's text alongside a story-card table, an "Update from scenario"
* dialog and an art picker. The fields worth keeping were the ones that
* actually reach the prompt; the rest was AI Dungeon's world-info editor, which
* the M7 knowledge library supersedes for v1.
*
* Canon gets its own editor here because it is the highest authority in the
* campaign and the thing a reader most often needs to correct *after* the story
* has tried to contradict it. It is written one rule per line and stored in the
* same document the prompt builder and the state validator already read.
*/
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { api } from '../../../api'
import { downloadJSON, useToast } from '../../../components'
import { ConfirmDialog } from '../../../Dialog'
import { classifyError } from '../../../errors'
export function CampaignSettingsPanel({ adventure, setAdventure, onError, moments = 0 }) {
const navigate = useNavigate()
const toast = useToast()
const [title, setTitle] = useState(adventure.title || '')
const [instructions, setInstructions] = useState(adventure.ai_instructions || '')
const [canon, setCanon] = useState((adventure.canon_rules || []).join('\n'))
const [persona, setPersona] = useState(adventure.persona_name || '')
const [personaDesc, setPersonaDesc] = useState(adventure.persona_desc || '')
const [saving, setSaving] = useState(false)
const [dirty, setDirty] = useState(false)
const [confirmDelete, setConfirmDelete] = useState(false)
// Re-seed if the campaign is replaced under us (an import, a restore).
useEffect(() => {
setTitle(adventure.title || '')
setInstructions(adventure.ai_instructions || '')
setCanon((adventure.canon_rules || []).join('\n'))
setPersona(adventure.persona_name || '')
setPersonaDesc(adventure.persona_desc || '')
setDirty(false)
}, [adventure.id]) // eslint-disable-line react-hooks/exhaustive-deps
const edit = (setter) => (event) => {
setter(event.target.value)
setDirty(true)
}
// Saved on an explicit press rather than on a debounce timer. A debounced
// save writes on every pause in typing, which is a request per few keystrokes
// against a field a reader edits for a minute at a time — the request-per-
// keystroke class of defect §38 looks for, arrived at politely.
const save = async () => {
setSaving(true)
try {
const updated = await api.updateAdventure(adventure.id, {
title: title.trim() || 'Untitled campaign',
ai_instructions: instructions,
canon_rules: canon.split('\n').map((r) => r.trim()).filter(Boolean),
persona_name: persona.trim(),
persona_desc: personaDesc,
})
setAdventure((prev) => ({ ...prev, ...updated }))
setDirty(false)
toast('Campaign settings saved.')
} catch (err) {
onError(err.message)
} finally {
setSaving(false)
}
}
const exportCampaign = async () => {
try {
const bundle = await api.exportAdventure(adventure.id)
const safe = (adventure.title || 'campaign').replace(/[^\w-]+/g, '_').slice(0, 60)
downloadJSON(bundle, `${safe}.json`)
toast('Campaign exported.')
} catch (err) {
onError(classifyError(err.message).detail)
}
}
const remove = async () => {
try {
await api.deleteAdventure(adventure.id)
navigate('/')
} catch (err) {
onError(classifyError(err.message).detail)
}
}
return (
<div className="campaign-settings">
<div className="field">
<label htmlFor="cs-title">Title</label>
<input id="cs-title" type="text" maxLength={120} value={title} onChange={edit(setTitle)} />
</div>
<div className="field">
<label htmlFor="cs-instructions">How the narrator should write</label>
<textarea
id="cs-instructions" rows={5} maxLength={8000}
value={instructions} onChange={edit(setInstructions)}
/>
<p className="field-hint">
Genre, tone, voice, length — anything you want true of every turn.
</p>
</div>
<div className="field">
<label htmlFor="cs-canon">Canon</label>
<textarea
id="cs-canon" rows={5} maxLength={8000}
value={canon} onChange={edit(setCanon)}
placeholder={'One rule per line.\nResurrection is impossible.'}
/>
<p className="field-hint">
One per line. The narrator is told these, and a turn that contradicts
one is refused. They outrank anything the story later invents.
</p>
{/* Canon is the highest authority in a campaign, so changing it once a
story exists must not read like correcting a typo. It is a
configuration change, not a retroactive one, and the distinction is
the thing a reader needs told: what is already written keeps the
canon it was written under, and each turn's context proves it.
Saying so here is what stops a post-play edit passing silently. */}
{moments > 0 && (
<p className="field-warning" data-testid="canon-history-warning">
This story already has {moments} {moments === 1 ? 'moment' : 'moments'}.
Changing canon changes what the narrator is told <strong>from here
on</strong>. Everything already written stays exactly as it is, and
each turn keeps a record of the canon it was actually given — open
<em> Inspect context</em> on any narrator turn to see it.
</p>
)}
</div>
<div className="field">
<label htmlFor="cs-persona">Protagonist</label>
<input
id="cs-persona" type="text" maxLength={80}
value={persona} onChange={edit(setPersona)}
/>
</div>
<div className="field">
<label htmlFor="cs-persona-desc">Who they are</label>
<textarea
id="cs-persona-desc" rows={3} maxLength={2000}
value={personaDesc} onChange={edit(setPersonaDesc)}
/>
</div>
<div className="panel-actions">
<button type="button" className="primary" onClick={save} disabled={saving || !dirty}>
{saving ? 'Saving…' : dirty ? 'Save changes' : 'Saved'}
</button>
</div>
<hr className="panel-rule" />
<div className="panel-actions">
<button type="button" onClick={exportCampaign}>Export campaign</button>
<button type="button" className="danger" onClick={() => setConfirmDelete(true)}>
Delete campaign
</button>
</div>
<p className="field-hint">
Export writes a single file holding the whole campaign — its story, its
Save Points, its state and everything imported into it.
</p>
{confirmDelete && (
<ConfirmDialog
title={`Delete “${adventure.title}”?`}
confirmLabel="Delete this campaign"
destructive
requireText={adventure.title}
requireLabel="Type the campaign name to confirm"
onCancel={() => setConfirmDelete(false)}
onConfirm={remove}
>
<p>
This permanently deletes the whole story, every Save Point, the
story state, and everything imported into it. It cannot be undone.
</p>
</ConfirmDialog>
)}
</div>
)
}
@@ -0,0 +1,407 @@
/* What the narrator was given, and what it cost.
*
* `BROWSER-UX-SPEC.md` §53-59 and §23 of the M8 brief. M6 and M7 already
* assembled all of this; what M8 owed was making it readable.
*
* The inherited panel showed everything at once — a metadata header, then every
* assembled prompt section dumped into a `<pre>`. On a two-turn campaign that
* measured just under twelve thousand characters of text, which is the "giant
* raw prompt" §55 says not to open with. The reader's question is almost never
* "what were the exact bytes"; it is "why did the narrator say that?", and the
* answer is one of four things: what it was told to be, what it remembered,
* what it read, or what it believes.
*
* So the default view is those four, summarised, with the exact prompt kept as
* an advanced section at the bottom (§55).
*
* ## Narrator-only passages
*
* §19 asks that narrator-only material not be revealed by accident. The
* narrative state has no hidden dimension — a secret never enters it, which M7
* verified against a real narrator — so the only place a campaign's secrets are
* reachable from a normal screen is here, in a retrieved passage that was
* marked narrator-only on import.
*
* That is a real spoiler, and it is shown behind an explicit control that is
* off by default and says what it will reveal. Turning it on is a preference in
* this component and nothing else: it reads no differently, retrieves no
* differently, and changes no story state (§92).
*/
import { useEffect, useState } from 'react'
import { api } from '../../../api'
import { SECTION_LABELS, pctLabel } from '../format'
import { CacheReport, WorldStateReport } from '../reports'
import { CLASS_LABEL } from './KnowledgePanel'
/** A collapsible block. Open by default for the sections a reader came for. */
function Section({ title, count, children, open = false, testId }) {
return (
<details className="ctx-block" open={open} data-testid={testId}>
<summary>
<span className="ctx-block-title">{title}</span>
{count != null && <span className="ctx-block-count">{count}</span>}
</summary>
<div className="ctx-block-body">{children}</div>
</details>
)
}
function TokenBar({ sections, total }) {
if (!sections.length || total <= 0) return null
return (
<div className="token-bar" role="img" aria-label="How the context is divided">
{sections.filter((s) => s.tokens > 0).map((s, i) => (
<span
key={i}
className={`token-bar-slice slice-${i % 8}`}
style={{ width: `${(s.tokens / total) * 100}%` }}
title={`${SECTION_LABELS[s.label] || s.label}: ${s.tokens} tokens`}
/>
))}
</div>
)
}
export function ContextPanel({
advId, inspectActionId, onClearInspect, refreshKey, onOpenSource,
}) {
const [report, setReport] = useState(null)
const [error, setError] = useState(null)
const [revealHidden, setRevealHidden] = useState(false)
useEffect(() => {
let stale = false
setError(null)
const load = inspectActionId
? api.getActionContext(advId, inspectActionId)
: api.getAdventureContext(advId)
load
.then((r) => { if (!stale) setReport(r) })
.catch((err) => { if (!stale) { setReport(null); setError(err.message) } })
return () => { stale = true }
}, [advId, inspectActionId, refreshKey])
// Turning the panel off a past turn should not leave a stale reveal on.
useEffect(() => { setRevealHidden(false) }, [inspectActionId])
if (error) return <div className="panel-empty">Couldn’t read the context — {error}</div>
if (!report) return <div className="panel-empty">Reading the context…</div>
const { tokens, history, sections, knowledge, memories, summary, derived } = report
const overBudget = tokens.total > tokens.budget
const sectionTotal = sections.reduce((n, s) => n + s.tokens, 0)
const failing = (derived || []).filter((d) => d.status === 'failed')
const summaryText = sections.find((s) => s.label === 'story_summary')?.text || ''
// Whether anything narrator-only reached this prompt at all. If it did, the
// assembled prompt below contains it verbatim — see the guard on that section.
const hasHidden = (knowledge?.used || []).some((k) => k.visibility === 'hidden')
return (
<div className="context-panel">
<div className="ctx-mode">
{inspectActionId ? (
<>
<span>What the narrator was given for this turn</span>
<button type="button" onClick={onClearInspect}>Show the next turn instead</button>
</>
) : (
<span>What the narrator will be given on the next turn</span>
)}
</div>
{/* ---- Token usage (§58) ---- */}
<div className={`ctx-tokens ${overBudget ? 'over' : ''}`} data-testid="ctx-tokens">
<div className="ctx-token-line">
<span>Story and instructions</span>
<strong>{tokens.total.toLocaleString()} tokens</strong>
</div>
{tokens.output_reserve > 0 && (
<div className="ctx-token-line dim">
<span>Held back for the reply</span>
<span>{tokens.output_reserve.toLocaleString()}</span>
</div>
)}
<div className="ctx-token-line dim">
<span>What the model can hold</span>
<span>{tokens.budget.toLocaleString()}</span>
</div>
<TokenBar sections={sections} total={sectionTotal} />
{overBudget && (
<p className="ctx-warn">
This is more than the model can hold. The oldest story will be
trimmed to fit.
</p>
)}
</div>
{failing.length > 0 && (
<div className="notice error" role="alert" data-testid="ctx-derived-failing">
<strong>Background work is failing</strong>
<p>
New {failing.map((d) => d.kind).join(' and ')} work is not being
written. Your story is unaffected, but the narrator will stop getting
fresh {failing.map((d) => d.kind).join(' and ')}.
</p>
{failing.map((d) => (
<p key={d.kind} className="notice-detail">{d.kind}: {d.detail}</p>
))}
</div>
)}
{/* ---- Retrieved knowledge (§57) ---- */}
<Section
title="Imported knowledge the narrator read"
count={knowledge?.used?.length ?? 0}
open
testId="ctx-knowledge"
>
{!knowledge || knowledge.generated === 0 ? (
<p className="dim">Nothing has been imported into this campaign.</p>
) : knowledge.used?.length === 0 ? (
<p className="dim" data-testid="ctx-knowledge-none">
{knowledge.generated} passage{knowledge.generated === 1 ? '' : 's'} were
looked at, and none were close enough to this scene to be worth
supplying.
{knowledge.terms?.length > 0 && (
<> Searched for: {knowledge.terms.slice(0, 10).join(', ')}.</>
)}
</p>
) : (
<>
{knowledge.used.some((k) => k.visibility === 'hidden') && (
<label className="reveal-toggle" data-testid="reveal-hidden">
<input
type="checkbox"
checked={revealHidden}
onChange={(e) => setRevealHidden(e.target.checked)}
/>
<span>
Show narrator-only text — this will reveal secrets you marked
for the narrator alone
</span>
</label>
)}
<ul className="ctx-rows">
{knowledge.used.map((k) => {
const hidden = k.visibility === 'hidden'
return (
<li
key={k.chunk_id}
className="ctx-row"
data-chunk-id={k.chunk_id}
data-classification={k.classification}
>
<div className="ctx-row-head">
{/* §57: click through to the source this came from. The
row carries `source_id`, so the Knowledge panel can be
opened on exactly that file rather than on a list the
reader then has to search. */}
{onOpenSource && k.source_id != null ? (
<button
type="button"
className="linklike ctx-source-link"
onClick={() => onOpenSource(k.source_id)}
>
{k.filename || k.title}
</button>
) : (
<strong>{k.filename || k.title}</strong>
)}
{/* The same label the Knowledge panel uses. A class that
reads "canon" here and "Canon" there is two things to
a reader, and §48 turns on the class being one. */}
<span className={`class-tag class-${k.classification}`}>
{CLASS_LABEL[k.classification] || k.classification}
</span>
{hidden && <span className="hidden-tag">narrator only</span>}
</div>
<div className="ctx-row-meta">
{k.heading_path && <span>{k.heading_path}</span>}
<span>passage {k.chunk_index + 1}</span>
{k.always_include
? <span>always included</span>
: (
<>
<span>found by {k.mode}</span>
{k.cosine > 0 && <span>closeness {k.cosine.toFixed(2)}</span>}
</>
)}
<span>{k.prompt_tokens} tokens</span>
</div>
{/* Rendered as a text node in a <pre>, never as markup —
this is where H06 and H07 are decided for imported
content, and it is why no Markdown renderer was added
here for appearance. */}
{hidden && !revealHidden ? (
<p className="ctx-row-withheld">
Hidden — this passage is narrator-only.
</p>
) : (
<pre className="ctx-row-text">{k.text}</pre>
)}
</li>
)
})}
</ul>
<p className="dim ctx-foot">
{knowledge.generated} passage{knowledge.generated === 1 ? '' : 's'} considered
{knowledge.rejected > 0 && `, ${knowledge.rejected} not close enough`}
{knowledge.spent > 0
&& `, ${knowledge.spent} of ${knowledge.budget} tokens used`}
</p>
{knowledge.suppressed?.map((k) => (
<p key={`s${k.chunk_id}`} className="dim">
{k.filename} passage {k.chunk_index + 1} — set aside as repeating
another passage.
</p>
))}
{knowledge.dropped?.map((k) => (
<p key={`d${k.chunk_id}`} className="dim">
{k.filename} passage {k.chunk_index + 1} — {k.reason} ({k.tokens} tokens).
</p>
))}
</>
)}
{knowledge?.semantic_note && (
<p className="ctx-note" data-testid="ctx-semantic-note">{knowledge.semantic_note}</p>
)}
</Section>
{/* ---- Retrieved memories (§56) ---- */}
<Section
title="What the story remembered"
count={memories?.used?.length ?? 0}
open
testId="ctx-memories"
>
{memories?.error && <p className="notice-detail error">{memories.error}</p>}
{!memories?.used?.length && !memories?.error && (
<p className="dim">Nothing was recalled for this turn.</p>
)}
<ul className="ctx-rows">
{memories?.used?.map((m, i) => (
<li key={i} className="ctx-row">
<div className="ctx-row-head">
<strong>
{m.authority === 'heuristic' ? 'Something inferred' : 'Something that happened'}
</strong>
{m.pinned
? <span className="class-tag">always kept</span>
: <span className="dim">closeness {m.similarity?.toFixed(2)}</span>}
</div>
<div className="ctx-row-meta">
{m.source?.depth != null && (
<span>
from moment {m.source.source_start === m.source.source_end
? m.source.depth
: `${m.source.source_start}–${m.source.source_end}`}
</span>
)}
</div>
<p className="ctx-row-text-plain">{m.text}</p>
</li>
))}
</ul>
{memories?.considered != null && (
<p className="dim ctx-foot">
{memories.considered} memories were eligible on this line of the story.
</p>
)}
</Section>
{/* ---- Summary (§59) ----
The provenance record carries where the summary came from but not its
words; the words are the assembled `story_summary` section, which is
the same text the narrator was sent. Read from there rather than
adding a second copy to the API. */}
<Section title="The story so far, as summarised" testId="ctx-summary">
{summary ? (
<>
{summaryText && <p className="ctx-row-text-plain">{summaryText}</p>}
<p className="dim ctx-foot">
Covers {summary.source_start != null
? `moments ${summary.source_start}–${summary.source_end}`
: `up to moment ${summary.source_end ?? summary.depth}`}
{summary.trigger === 'manual' ? ' · written by you' : ' · written for you'}
</p>
</>
) : (
<p className="dim">
No summary applies at this point in the story yet.
</p>
)}
</Section>
{/* ---- Model settings (§54) ---- */}
<Section title="What produced this" testId="ctx-settings">
<dl className="ctx-settings">
<dt>Model</dt><dd>{report.settings?.model || '(none chosen)'}</dd>
<dt>Temperature</dt><dd>{report.settings?.temperature}</dd>
<dt>Longest reply</dt><dd>{report.settings?.max_output_tokens} tokens</dd>
</dl>
</Section>
{/* ---- History ---- */}
<Section title="How much of the story was included" testId="ctx-history">
<p>
{history.included} of {history.total} moments were sent.
{history.total > history.included && ' Older ones were trimmed to fit.'}
{history.oldest_truncated && ' The oldest one was cut mid-sentence.'}
</p>
<CacheReport usage={report.usage} />
</Section>
<WorldStateReport worldState={report.world_state} />
{/* ---- The exact prompt (§55) ----
Withheld entirely while narrator-only material is in it and the reader
has not asked to see it. The passage rows above hide the secret one by
one; this section is the *assembled* prompt, so it contains the same
text verbatim — and a closed `<details>` still holds its contents in
the DOM, where find-in-page reaches it. Hiding it visually would have
been the appearance of a guard rather than a guard, which is what the
sentinel test was written to catch. */}
<Section
title="The exact text the narrator was sent"
count={hasHidden && !revealHidden ? 'withheld' : `${sectionTotal.toLocaleString()} tokens`}
testId="ctx-raw"
>
{hasHidden && !revealHidden ? (
<p className="ctx-row-withheld" data-testid="ctx-raw-withheld">
The assembled prompt contains a narrator-only passage, so it is
withheld. Turn on <em>Show narrator-only text</em> above to read it —
it will reveal secrets you marked for the narrator alone.
</p>
) : (
<>
<p className="dim">
This is the assembled prompt, section by section, exactly as it went to
the model.
</p>
{sections.map((s, i) => (
<div key={i} className="ctx-raw-section">
<div className="ctx-raw-head">
<span>{SECTION_LABELS[s.label] || s.label}</span>
<span>
{s.tokens} tok
{sectionTotal > 0 && ` · ${pctLabel((s.tokens / sectionTotal) * 100)}`}
</span>
</div>
<pre>{s.text}</pre>
</div>
))}
{report.raw_output && (
<div className="ctx-raw-section">
<div className="ctx-raw-head">
<span>What came back, before the state block was removed</span>
</div>
<pre>{report.raw_output}</pre>
</div>
)}
</>
)}
</Section>
</div>
)
}
@@ -1,261 +0,0 @@
// The insights panel: what the last turn sent, and what it cost.
import { useEffect, useState } from 'react'
import { api } from '../../../api'
import { SECTION_LABELS, pctLabel, sectionColor } from '../format'
import { CacheReport, TokenBreakdown, WorldStateReport } from '../reports'
function InsightsPanel({ advId, inspectActionId, onClearInspect, refreshKey }) {
const [report, setReport] = useState(null)
const [error, setError] = useState(null)
useEffect(() => {
let stale = false // a slow earlier request must not clobber a newer one
setError(null)
const load = inspectActionId
? api.getActionContext(advId, inspectActionId)
: api.getAdventureContext(advId)
load
.then((r) => { if (!stale) setReport(r) })
.catch((err) => { if (!stale) { setReport(null); setError(err.message) } })
return () => { stale = true }
}, [advId, inspectActionId, refreshKey])
if (error) return <div className="empty">{error}</div>
if (!report) return <div className="empty">Loading…</div>
const { tokens, cards, history, sections } = report
const overBudget = tokens.total > tokens.budget
// The per-section sum, not tokens.total: the total also counts the separators
// between sections, and percentages have to add up to 100 for the reader.
const sectionTotal = sections.reduce((n, s) => n + s.tokens, 0)
const jumpToSection = (i) => {
document.getElementById(`ctx-sec-${i}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
return (
<div className="insights">
<div className="insights-meta">
{inspectActionId ? (
<div className="insights-mode">
Snapshot of a past turn
<button onClick={onClearInspect} style={{ marginLeft: 8 }}>Back to next turn</button>
</div>
) : (
<div className="insights-mode">What will be sent on the next turn</div>
)}
<div className={`token-total ${overBudget ? 'over' : ''}`}>
{tokens.total} / {tokens.budget} tokens
{/* M6: the reply has to fit in the same window, so say how much of it
is being held back for one. Without this the reader can see that
the prompt fits and still get a truncated turn. */}
{tokens.output_reserve > 0 && (
<span className="dim"> · {tokens.output_reserve} reserved for the reply</span>
)}
</div>
<TokenBreakdown
sections={sections}
tokens={tokens}
used={sectionTotal}
onJump={jumpToSection}
/>
<div className="insights-history">
History: {history.included} of {history.total} actions in context
{history.total > history.included && ' (older history trimmed)'}
{history.oldest_truncated && ' — oldest entry cut mid-text'}
</div>
<CacheReport usage={report.usage} />
{cards.length > 0 && (
<div className="insights-cards">
{cards.map((c, i) => (
<div key={i} className={c.included ? '' : 'dropped'}>
▸ <b>{c.name || '(unnamed card)'}</b> triggered on “{c.keyword}”
{!c.included && ' — dropped (over card budget)'}
</div>
))}
</div>
)}
{report.memories && (
<div className="insights-cards">
{report.memories.error && (
<div className="dropped">⚠ Memory bank: {report.memories.error}</div>
)}
{report.memories.used?.map((m, i) => (
<div key={i}>
▸ memory retrieved ({m.pinned ? 'pinned' : `similarity ${m.similarity.toFixed(2)}`})
{/* M6: what weight it carries, and where it came from. An
inference must not read like a record. */}
{m.authority === 'heuristic'
? <span className="mem-heuristic"> · inferred</span>
: <span className="dim"> · from the story</span>}
{m.source?.depth != null && (
<span className="dim">
{' '}· turn {m.source.source_start === m.source.source_end
? m.source.depth
: `${m.source.source_start}–${m.source.source_end}`}
</span>
)}
: {m.text.length > 90 ? m.text.slice(0, 90) + '…' : m.text}
</div>
))}
{report.memories.considered != null && (
<div className="dim">
{report.memories.considered} memories eligible on this line of the story.
</div>
)}
{!report.memories.error && report.memories.used?.length === 0 && (
<div className="dim">Memory bank on — no memories retrieved.</div>
)}
</div>
)}
{/* M7: which imported passages the narrator was given, and why each of
them won. This is F05's "retrieved knowledge" row and F06's imported
half: the file, the class, the visibility, the passage, how it was
found, what each retrieval path scored it, and what it cost.
Rendered as text, never as markup — the passage text below is a
React child in a <pre>, so imported script or a javascript: URL is
inert here exactly as it is in the Knowledge panel (H06, H07). */}
{/* M7 corrective: "nothing matched" is a real answer and has to be
said. Retrieval can now return no passages at all, and a panel that
simply showed nothing would be indistinguishable from a library that
was never searched. */}
{report.knowledge && report.knowledge.generated > 0
&& report.knowledge.used?.length === 0 && (
<div className="insights-cards" data-testid="insights-knowledge-none">
<div className="dim">
▸ Imported knowledge: {report.knowledge.generated} passage(s)
{' '}considered, none relevant enough to this scene to be supplied.
{report.knowledge.terms?.length > 0 && (
<> Searched on: {report.knowledge.terms.slice(0, 10).join(', ')}.</>
)}
</div>
</div>
)}
{report.knowledge && (report.knowledge.used?.length > 0
|| report.knowledge.suppressed?.length > 0
|| report.knowledge.dropped?.length > 0) && (
<div className="insights-cards" data-testid="insights-knowledge">
{report.knowledge.used?.map((k) => (
<div key={`k${k.chunk_id}`} className="knowledge-used"
data-chunk-id={k.chunk_id} data-classification={k.classification}>
▸ <b>{k.filename || k.title}</b>
<span className={`knowledge-class knowledge-class-${k.classification}`}>
{k.classification}
</span>
{k.heading_path && <span className="dim"> · {k.heading_path}</span>}
<span className="dim"> · passage {k.chunk_index + 1}</span>
{k.visibility === 'hidden' && (
<span className="knowledge-badge" title="Supplied to the narrator only">
narrator only
</span>
)}
{k.always_include
? <span className="dim"> · always included</span>
: (
<span className="dim">
{' '}· {k.mode} match
{k.matched_terms?.length > 0
&& ` on ${k.matched_terms.slice(0, 6).join(', ')}`}
{k.cosine > 0 && ` · similarity ${k.cosine.toFixed(2)}`}
{' '}· rank {k.score.toFixed(2)}
</span>
)}
<span className="dim"> · {k.prompt_tokens} tok</span>
<pre className="knowledge-text">{k.text}</pre>
</div>
))}
{report.knowledge.suppressed?.map((k) => (
<div key={`ks${k.chunk_id}`} className="dropped">
▸ {k.filename} passage {k.chunk_index + 1} — set aside as
repeating passage {k.duplicate_of}
</div>
))}
{report.knowledge.dropped?.map((k) => (
<div key={`kd${k.chunk_id}`} className="dropped">
▸ {k.filename} passage {k.chunk_index + 1} — {k.reason}
{' '}({k.tokens} tok)
</div>
))}
<div className="dim">
{report.knowledge.generated} passage(s) considered
{report.knowledge.rejected > 0
&& `, ${report.knowledge.rejected} not relevant enough`}
{report.knowledge.spent > 0 && (
<>, {report.knowledge.spent} of {report.knowledge.budget} knowledge tokens used</>
)}
{report.knowledge.terms?.length > 0 && (
<> · searched on: {report.knowledge.terms.slice(0, 10).join(', ')}</>
)}
</div>
{report.knowledge.semantic_note && (
<div className="dim">{report.knowledge.semantic_note}</div>
)}
</div>
)}
{/* M6: which summary was used, and what stretch of story it covers, so
"what history did that summary cover?" is answerable here. */}
{report.summary ? (
<div className="insights-cards">
<div>
▸ summary in use
<span className="dim">
{' '}· covers {report.summary.source_start != null
? `turns ${report.summary.source_start}–${report.summary.source_end}`
: `up to turn ${report.summary.source_end ?? report.summary.depth}`}
{report.summary.trigger === 'manual' ? ' · written by you' : ' · generated'}
</span>
</div>
</div>
) : (
<div className="insights-cards dim">▸ no summary is eligible for this point in the story.</div>
)}
{/* M6: a dead memory bank used to be invisible. It is not any more. */}
{report.derived?.some((d) => d.status === 'failed') && (
<div className="insights-cards">
{report.derived.filter((d) => d.status === 'failed').map((d) => (
<div key={d.kind} className="dropped">
⚠ Background {d.kind} work is failing ({d.failures}
{d.failures === 1 ? ' attempt' : ' attempts'}): {d.detail}
</div>
))}
<div className="dim">
The story itself is unaffected — this only stops new {' '}
{report.derived.filter((d) => d.status === 'failed')
.map((d) => d.kind).join(' and ')} work being written.
</div>
</div>
)}
</div>
{sections.map((s, i) => (
<div
key={i}
id={`ctx-sec-${i}`}
className={`ctx-section ctx-${s.label}`}
style={{ borderLeftColor: sectionColor(s.label) }}
>
<div className="ctx-header" style={{ color: sectionColor(s.label) }}>
<span>{SECTION_LABELS[s.label] || s.label}</span>
<span>
{s.tokens} tok
{sectionTotal > 0 && ` · ${pctLabel((s.tokens / sectionTotal) * 100)}`}
</span>
</div>
<pre>{s.text}</pre>
</div>
))}
<WorldStateReport worldState={report.world_state} />
{report.raw_output && (
<div className="ctx-section">
<div className="ctx-header">
<span>Raw AI output (before state block was stripped)</span>
</div>
<pre>{report.raw_output}</pre>
</div>
)}
</div>
)
}
export { InsightsPanel }
+354 -163
View File
@@ -1,26 +1,63 @@
// M7: the imported knowledge library — import, classify, inspect, disable, delete.
//
// Functional rather than finished. M8 owns the designed knowledge surface; what
// this has to do is make every M7 behaviour reachable in a browser without
// anyone opening the database, which is the milestone's own standard.
//
// **Nothing here renders imported text as HTML.** Source text and passage text
// both go into a `<pre>` as React children, which React escapes — so a
// `<script>` in a file is five visible characters and a `javascript:` URL is
// never an href, on first inspection and on every reopen (H06, H07). A remote
// Markdown image reference is likewise just characters: no `<img>` is created,
// so no request is made (G09). Adding a Markdown renderer would buy appearance
// and cost exactly those three properties. `SECURITY-THREAT-MODEL.md` §14 names
// sanitized presentation text as the safer default; rendering no markup at all
// is one step safer still.
/* The imported knowledge library.
*
* M7 built every behaviour here and M8 owns how it reads
* (`BROWSER-UX-SPEC.md` §46-52, §21-22 of the M8 brief). What changed is
* presentation and vocabulary, not capability: the same imports, the same
* classification, enable/disable, narrator-only, always-include, delete,
* inspect and rebuild.
*
* **Nothing here renders imported text as HTML.** Source text and passage text
* both go into a `<pre>` as React children, which React escapes — so a
* `<script>` in a file is visible characters, a `javascript:` URL is never an
* href, and a remote Markdown image reference creates no `<img>` and therefore
* makes no request (H06, H07, G09). The safe Markdown renderer the transcript
* uses is deliberately *not* used here: this panel exists to show a reader
* exactly what is in their file, and rendering is the opposite of that.
* `SECURITY-THREAT-MODEL.md` §14 names sanitized presentation text as the safer
* default; rendering no markup at all is one step safer still.
*
* ## The classification is explained, not iconified
*
* §48 is explicit that the three classes must not rely on unexplained icons,
* because the difference between them is the whole feature: it decides the
* words a passage is framed with in the prompt, the weight it carries in
* ranking, and which budget it competes in. The explanations are on the import
* form where the decision is made, not in a tooltip.
*
* ## Semantic status
*
* §22. Three states have to be told apart and the inherited line ran them
* together: searching by meaning is on; it is off because no embedding model is
* configured, which is a supported setup; or a model *is* configured and is not
* being used because M7 has no measured calibration for it, so retrieval
* degraded to keyword-only. The third is the one that reads as a mysterious
* failure if it is not explained, and it is the one M7's closeout added.
*
* No threshold is exposed. `0.58` is a measured property of one embedding
* model, not a preference, and a slider over it would invite a reader to
* recreate the defect M7's review found.
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import { api } from '../../../api'
import { ConfirmDialog } from '../../../Dialog'
const CLASSES = [
{ value: 'canon', label: 'Canon', hint: 'Authoritative truth for this campaign.' },
{ value: 'reference', label: 'Reference', hint: 'Supporting information; does not establish story truth.' },
{ value: 'inspiration', label: 'Inspiration', hint: 'Creative and style influence only.' },
{
value: 'canon',
label: 'Canon',
hint: 'Authoritative truth for this campaign. The narrator may not contradict it.',
},
{
value: 'reference',
label: 'Reference',
hint: 'Supporting information. It informs description but does not establish story truth.',
},
{
value: 'inspiration',
label: 'Inspiration',
hint: 'Creative and style influence only. Never a source of fact.',
},
]
const CLASS_LABEL = Object.fromEntries(CLASSES.map((c) => [c.value, c.label]))
@@ -34,7 +71,21 @@ function bytes(n) {
function when(iso) {
if (!iso) return ''
const d = new Date(iso)
return Number.isNaN(d.getTime()) ? '' : d.toLocaleString()
return Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString()
}
/** The three classes, said in full. Shown where the choice is made. */
function ClassLegend() {
return (
<dl className="class-legend">
{CLASSES.map((c) => (
<div key={c.value}>
<dt><span className={`class-tag class-${c.value}`}>{c.label}</span></dt>
<dd>{c.hint}</dd>
</div>
))}
</dl>
)
}
function SourceDetail({ advId, source, onError }) {
@@ -42,17 +93,18 @@ function SourceDetail({ advId, source, onError }) {
const [chunks, setChunks] = useState(null)
const [showText, setShowText] = useState(false)
const [showChunks, setShowChunks] = useState(false)
// The error reporter reaches this component through a ref rather than
// through the effect's dependencies. It arrives as a fresh arrow function on
// every render of the Play screen — which re-renders on every keystroke in
// the story box — so depending on it would refetch the source, and the
// source text, once per character typed. The ref keeps the *current*
// reporter without making it a reason to re-run.
const [showAdvanced, setShowAdvanced] = useState(false)
// The error reporter reaches this component through a ref rather than the
// effect's dependencies. It arrives as a fresh arrow on every render of the
// Play screen — which re-renders on every keystroke in the story box — so
// depending on it would refetch the source once per character typed. Found by
// M7's browser run; the ref keeps the *current* reporter without making it a
// reason to re-run.
const report = useRef(onError)
report.current = onError
useEffect(() => {
let stale = false // a slow earlier request must not clobber a newer one
let stale = false
setDetail(null)
api.getKnowledgeSource(advId, source.id)
.then((r) => { if (!stale) setDetail(r) })
@@ -70,29 +122,31 @@ function SourceDetail({ advId, source, onError }) {
return (
<div className="knowledge-detail">
<div className="dim knowledge-facts">
<div>File: {source.original_filename}</div>
<div>Imported: {when(source.imported_at)}</div>
<div>Size: {bytes(source.byte_size)} · {source.media_type}</div>
<div>Passages: {source.chunk_count} · embedded {source.embedded_count}</div>
<div>Parser v{source.parser_version} · chunking v{source.chunking_version}</div>
{/* The content identity, in full rather than shortened: it is here to be
compared against another copy, and half a digest compares nothing. */}
<div className="knowledge-hash">SHA-256: {source.content_hash}</div>
</div>
<dl className="knowledge-facts">
<div><dt>File</dt><dd>{source.original_filename}</dd></div>
<div><dt>Imported</dt><dd>{when(source.imported_at)}</dd></div>
<div><dt>Size</dt><dd>{bytes(source.byte_size)}</dd></div>
<div>
<dt>Passages</dt>
<dd>{source.chunk_count} · {source.embedded_count} searchable by meaning</dd>
</div>
</dl>
<div className="knowledge-detail-buttons">
<button onClick={() => setShowText((open) => !open)}>
{showText ? 'Hide source text' : 'Inspect source text'}
<button type="button" onClick={() => setShowText((open) => !open)}>
{showText ? 'Hide the text' : 'Read the text'}
</button>
<button onClick={loadChunks}>
{showChunks ? 'Hide passages' : `Inspect passages (${source.chunk_count})`}
<button type="button" onClick={loadChunks}>
{showChunks ? 'Hide passages' : `Show the ${source.chunk_count} passages`}
</button>
</div>
{showText && (
detail
? <pre className="knowledge-text" data-testid="knowledge-source-text">{detail.content}</pre>
: <div className="empty">Loading…</div>
: <p className="dim">Loading…</p>
)}
{showChunks && (
chunks
? chunks.map((chunk) => (
@@ -100,111 +154,197 @@ function SourceDetail({ advId, source, onError }) {
<div className="dim">
passage {chunk.chunk_index + 1}
{chunk.heading_path && ` · ${chunk.heading_path}`}
{' '}· {chunk.token_count} tok
{chunk.embedded ? ` · embedded (${chunk.embedding_model})` : ' · not embedded'}
{' '}· {chunk.token_count} tokens
{chunk.embedded ? ' · searchable by meaning' : ' · keyword search only'}
</div>
<pre className="knowledge-text">{chunk.text}</pre>
</div>
))
: <div className="empty">Loading…</div>
: <p className="dim">Loading…</p>
)}
{/* §49's advanced half: the content identity and the parser versions. Of
interest when comparing two copies of a file, and noise otherwise. */}
<details className="knowledge-advanced" open={showAdvanced}
onToggle={(e) => setShowAdvanced(e.currentTarget.open)}>
<summary>Technical details</summary>
<dl className="knowledge-facts">
<div><dt>Type</dt><dd>{source.media_type}</dd></div>
<div>
<dt>Versions</dt>
<dd>parser v{source.parser_version} · chunking v{source.chunking_version}</dd>
</div>
</dl>
{/* In full rather than shortened: it is here to be compared against
another copy, and half a digest compares nothing. */}
<p className="knowledge-hash">SHA-256: {source.content_hash}</p>
</details>
</div>
)
}
function SourceRow({ advId, source, onChange, onDelete, onError }) {
const [open, setOpen] = useState(false)
function SourceRow({ advId, source, open, onToggle, onChange, onDelete, onError }) {
const [confirming, setConfirming] = useState(false)
return (
<div className={`knowledge-row ${source.enabled ? '' : 'disabled'}`}
data-source-id={source.id} data-classification={source.classification}>
<li
className={`knowledge-row ${source.enabled ? '' : 'disabled'}`}
data-source-id={source.id}
data-classification={source.classification}
>
<div className="knowledge-head">
<button className="knowledge-title" onClick={() => setOpen((o) => !o)}>
{open ? '▾' : '▸'} {source.title}
<button
type="button"
className="knowledge-title"
aria-expanded={open}
onClick={onToggle}
>
<span aria-hidden="true">{open ? '▾' : '▸'}</span> {source.title}
</button>
{/* The filename beside the title, not only inside the detail. A title
defaults to the filename without its extension, so two files that
differ only by type would otherwise be indistinguishable in the
list — and the filename is the name the reader knows the file by. */}
{source.original_filename && source.original_filename !== source.title && (
<span className="dim knowledge-filename">{source.original_filename}</span>
)}
<span className={`knowledge-class knowledge-class-${source.classification}`}>
<span className={`class-tag class-${source.classification}`}>
{CLASS_LABEL[source.classification]}
</span>
{!source.enabled && <span className="knowledge-badge">disabled</span>}
{!source.enabled && <span className="knowledge-badge">not in use</span>}
{source.visibility === 'hidden' && (
<span className="knowledge-badge" title="Given to the narrator; the protagonist does not know it">
narrator only
</span>
<span className="hidden-tag">narrator only</span>
)}
{source.always_include && <span className="knowledge-badge">always included</span>}
{source.index_state === 'failed' && (
<span className="knowledge-badge failed" title={source.index_detail}>index failed</span>
<span className="knowledge-badge failed" title={source.index_detail}>
could not be indexed
</span>
)}
{source.embed_state === 'failed' && (
<span className="knowledge-badge failed" title={source.embed_detail}>
semantic failed — lexical search still works
keyword search only
</span>
)}
</div>
<p className="knowledge-sub">
{/* A title defaults to the filename without its extension, so two files
differing only by type would otherwise look identical in the list. */}
{source.original_filename && source.original_filename !== source.title && (
<span>{source.original_filename} · </span>
)}
<span>imported {when(source.imported_at)}</span>
<span> · {source.chunk_count} passage{source.chunk_count === 1 ? '' : 's'}</span>
</p>
<div className="knowledge-controls">
<label>
Class{' '}
<select value={source.classification}
onChange={(e) => onChange(source, { classification: e.target.value })}>
<span className="sr-only">Classification for {source.title}</span>
<select
value={source.classification}
aria-label={`Classification for ${source.title}`}
onChange={(e) => onChange(source, { classification: e.target.value })}
>
{CLASSES.map((c) => <option key={c.value} value={c.value}>{c.label}</option>)}
</select>
</label>
<label>
<input type="checkbox" checked={source.enabled}
onChange={(e) => onChange(source, { enabled: e.target.checked })} />
{' '}Enabled
<input
type="checkbox"
checked={source.enabled}
onChange={(e) => onChange(source, { enabled: e.target.checked })}
/>
{' '}In use
</label>
<label>
<input type="checkbox" checked={source.visibility === 'hidden'}
<input
type="checkbox"
checked={source.visibility === 'hidden'}
onChange={(e) => onChange(source, {
visibility: e.target.checked ? 'hidden' : 'normal',
})} />
})}
/>
{' '}Narrator only
</label>
{/* Canon's alone. The server enforces it too — this only stops the
control offering something that would be silently ignored. */}
{source.classification === 'canon' && (
<label>
<input type="checkbox" checked={source.always_include}
onChange={(e) => onChange(source, { always_include: e.target.checked })} />
<input
type="checkbox"
checked={source.always_include}
onChange={(e) => onChange(source, { always_include: e.target.checked })}
/>
{' '}Always include
</label>
)}
{confirming ? (
<span className="knowledge-confirm">
Delete “{source.title}”? It stops being used from now on. Turns that
already used it keep their record of what they were given.{' '}
<button className="danger" onClick={() => onDelete(source)}>Delete</button>
<button onClick={() => setConfirming(false)}>Cancel</button>
</span>
) : (
<button className="danger" onClick={() => setConfirming(true)}>Delete</button>
)}
<button type="button" className="danger" onClick={() => setConfirming(true)}>
Delete
</button>
</div>
{open && (
<SourceDetail advId={advId} source={source} onError={onError} />
{open && <SourceDetail advId={advId} source={source} onError={onError} />}
{confirming && (
<ConfirmDialog
title={`Delete “${source.title}”?`}
confirmLabel="Delete this source"
destructive
onCancel={() => setConfirming(false)}
onConfirm={() => { setConfirming(false); onDelete(source) }}
>
<p>
It stops being used from now on, and the story will no longer draw
on it.
</p>
<p>
Turns that already used it are unchanged — each one keeps its own
record of what the narrator was given.
</p>
<p className="dim">
If you only want to stop using it for a while, turn off “In use”
instead. That is reversible.
</p>
</ConfirmDialog>
)}
</div>
</li>
)
}
/** §22, in three cases rather than a boolean. */
function SemanticStatus({ status }) {
if (!status) return null
const model = status.embedding_model
if (status.semantic_enabled) {
return (
<p className="dim" data-testid="semantic-status" data-state="on">
Searching by keyword and by meaning.
{status.pending_embeddings > 0
&& ` ${status.pending_embeddings} passage(s) are still being prepared.`}
</p>
)
}
if (!model) {
return (
<p className="dim" data-testid="semantic-status" data-state="lexical-only">
Searching by keyword. Setting a <em>model for meaning-based search</em> in
Settings would let it search by meaning as well — keyword search alone is
a supported setup, and it is what finds names and invented terms.
</p>
)
}
// Configured but not calibrated: the M7 closeout case.
return (
<p className="notice-detail" data-testid="semantic-status" data-state="uncalibrated">
Searching by keyword only. <code>{model}</code>, the model set for
meaning-based search, has not been measured for this in this build, so it
is not used to decide what is relevant — borrowing another model’s
measurement would let unrelated material through. Keyword search is
unaffected.
{status.calibrated_models?.length > 0 && (
<> Measured so far: {status.calibrated_models.join(', ')}.</>
)}
</p>
)
}
// A stable no-op for the `onError`-less case. Defined once, at module scope,
// because an inline `onError || (() => {})` would hand every row a different
// function on every render — which is the same identity problem `SourceDetail`
// guards against, arriving from the other side.
const NO_OP = () => {}
function KnowledgePanel({ advId, refreshKey, onError }) {
function KnowledgePanel({ advId, refreshKey, onError, focusSourceId }) {
const [sources, setSources] = useState(null)
const [status, setStatus] = useState(null)
const [file, setFile] = useState(null)
@@ -214,6 +354,7 @@ function KnowledgePanel({ advId, refreshKey, onError }) {
const [busy, setBusy] = useState(false)
const [notice, setNotice] = useState(null)
const [duplicate, setDuplicate] = useState(null)
const [openId, setOpenId] = useState(null)
const load = useCallback(() => {
api.listKnowledge(advId).then(setSources).catch(() => setSources([]))
@@ -222,6 +363,11 @@ function KnowledgePanel({ advId, refreshKey, onError }) {
useEffect(() => { load() }, [load, refreshKey])
// Opened from a retrieved passage in the Context panel (§57).
useEffect(() => {
if (focusSourceId != null) setOpenId(focusSourceId)
}, [focusSourceId])
const doImport = async (allowDuplicate = false) => {
if (!file) return
setBusy(true)
@@ -236,9 +382,9 @@ function KnowledgePanel({ advId, refreshKey, onError }) {
setFile(null)
setTitle('')
setDuplicate(null)
// The input is uncontrolled (a file input cannot be controlled), so it is
// cleared through the DOM. Without this, re-picking the same file after a
// failed import fires no change event and the button does nothing.
// A file input cannot be controlled, so it is cleared through the DOM.
// Without this, re-picking the same file after a failed import fires no
// change event and the button does nothing.
const input = document.getElementById('knowledge-file')
if (input) input.value = ''
setNotice('Imported.')
@@ -274,7 +420,7 @@ function KnowledgePanel({ advId, refreshKey, onError }) {
setNotice(
`Rebuilt ${out.chunks} passage${out.chunks === 1 ? '' : 's'} across `
+ `${out.sources} source${out.sources === 1 ? '' : 's'}`
+ (out.semantic ? `, embedded ${out.embedded}.` : '. Semantic index not configured.')
+ (out.semantic ? `, ${out.embedded} searchable by meaning.` : '.'),
)
load()
} catch (err) {
@@ -285,97 +431,142 @@ function KnowledgePanel({ advId, refreshKey, onError }) {
return (
<div className="knowledge-panel">
<p className="dim">
Local <code>.txt</code> and <code>.md</code> files this campaign can draw on.
Nothing is uploaded anywhere, no link in a file is ever fetched, and text
in a source is never treated as an instruction to the application.
Local <code>.txt</code> and <code>.md</code> files this campaign can draw
on. Nothing is uploaded anywhere, no link in a file is ever followed, and
text in a file is never treated as an instruction to the application.
</p>
<div className="knowledge-import">
<input id="knowledge-file" type="file" accept=".txt,.md,text/plain,text/markdown"
onChange={(e) => { setFile(e.target.files?.[0] || null); setDuplicate(null) }} />
<label>
Class{' '}
<select value={classification} onChange={(e) => setClassification(e.target.value)}>
{CLASSES.map((c) => <option key={c.value} value={c.value}>{c.label}</option>)}
</select>
</label>
<div className="dim">{CLASSES.find((c) => c.value === classification)?.hint}</div>
<input type="text" placeholder="Title (optional)" value={title}
onChange={(e) => setTitle(e.target.value)} />
<label>
<input type="checkbox" checked={hidden} onChange={(e) => setHidden(e.target.checked)} />
{' '}Narrator only — the protagonist does not know this
</label>
<button className="primary" id="knowledge-import" disabled={!file || busy}
onClick={() => doImport(false)}>
{busy ? 'Working…' : 'Import file'}
</button>
</div>
<details className="knowledge-import-block" open={sources?.length === 0}>
<summary>Import a file</summary>
<div className="knowledge-import">
<div className="field">
<label htmlFor="knowledge-file">Choose a file</label>
<input
id="knowledge-file"
type="file"
accept=".txt,.md,text/plain,text/markdown"
onChange={(e) => { setFile(e.target.files?.[0] || null); setDuplicate(null) }}
/>
</div>
<fieldset className="knowledge-class-choice">
<legend>How should the story treat it?</legend>
{CLASSES.map((c) => (
<label key={c.value} className="class-choice">
<input
type="radio"
name="knowledge-class"
value={c.value}
checked={classification === c.value}
onChange={() => setClassification(c.value)}
/>
<span className={`class-tag class-${c.value}`}>{c.label}</span>
<span className="class-choice-hint">{c.hint}</span>
</label>
))}
</fieldset>
<div className="field">
<label htmlFor="knowledge-title">Title (optional)</label>
<input
id="knowledge-title"
type="text"
value={title}
placeholder="Defaults to the filename"
onChange={(e) => setTitle(e.target.value)}
/>
</div>
<label className="knowledge-hidden-choice">
<input
type="checkbox"
checked={hidden}
onChange={(e) => setHidden(e.target.checked)}
/>
{' '}Narrator only — the narrator knows it, your character does not
</label>
<button
type="button"
className="primary"
id="knowledge-import"
disabled={!file || busy}
onClick={() => doImport(false)}
>
{busy ? 'Working…' : 'Import file'}
</button>
</div>
</details>
{notice && <div className="knowledge-notice" id="knowledge-notice">{notice}</div>}
{duplicate && (
<div className="knowledge-notice">
Already imported as “{duplicate.title}” ({CLASS_LABEL[duplicate.classification]}).{' '}
<button onClick={() => doImport(true)}>Import a second copy anyway</button>
<button type="button" onClick={() => doImport(true)}>
Import a second copy anyway
</button>
</div>
)}
{status && (
<div className="dim knowledge-status">
{status.sources} source{status.sources === 1 ? '' : 's'},
{' '}{status.enabled_sources} enabled ·{' '}
{status.semantic_enabled
? `semantic search on (${status.embedding_model})`
+ (status.pending_embeddings
? `, ${status.pending_embeddings} passage(s) still to embed`
: '')
: 'semantic search off — lexical search only, which is a supported setup'}
{/* A configured but uncalibrated model is neither "on" nor simply
"off": the reader chose a model and it is not being used for
retrieval, so the reason has to be visible. */}
{status.embedding_model && !status.semantic_calibrated && (
<div className="dropped" data-testid="knowledge-uncalibrated">
⚠ {status.semantic_note} Calibrated in this build:{' '}
{(status.calibrated_models || []).join(', ')}.
</div>
)}
<div className="knowledge-status">
<SemanticStatus status={status} />
{status.failed_index.length > 0 && (
<div className="dropped">
⚠ {status.failed_index.length} source(s) failed to index and cannot be retrieved.
</div>
<p className="notice-detail error">
{status.failed_index.length} file(s) could not be indexed and cannot
be found by search.
</p>
)}
{status.failed_embedding.length > 0 && (
<div className="dropped">
⚠ {status.failed_embedding.length} source(s) failed to embed. Lexical
retrieval still works for them; Rebuild indexes to retry.
</div>
<p className="notice-detail">
{status.failed_embedding.length} file(s) could not be prepared for
meaning-based search. Keyword search still finds them.
</p>
)}
</div>
)}
<div className="page-header" style={{ marginTop: 16 }}>
<h3 style={{ margin: 0 }}>Sources {sources && `(${sources.length})`}</h3>
<span>
<button onClick={reindex} disabled={busy} title="Rebuild passages and search indexes from the stored text. Story history is untouched.">
Rebuild indexes
</button>
<button onClick={load} style={{ marginLeft: 6 }}>Refresh</button>
</span>
<div className="knowledge-list-head">
<h3>In this campaign {sources && `(${sources.length})`}</h3>
<button
type="button"
onClick={reindex}
disabled={busy}
title="Rebuild passages and search indexes from the stored text. Your story is untouched."
>
Rebuild search
</button>
</div>
{!sources && <div className="empty" style={{ padding: '12px 0' }}>Loading…</div>}
{!sources && <p className="dim">Loading…</p>}
{sources && sources.length === 0 && (
<div className="empty" style={{ padding: '12px 0' }}>
Nothing imported yet. Add a setting bible, character notes, a research
file or a passage you want the prose to feel like.
<div className="knowledge-empty">
<p>Nothing imported yet.</p>
<p className="dim">
A setting bible, character notes, a research file, or a passage whose
style you want the prose to feel like.
</p>
<ClassLegend />
</div>
)}
{sources?.map((source) => (
<SourceRow key={source.id} advId={advId} source={source}
onChange={change} onDelete={remove} onError={onError || NO_OP} />
))}
{sources?.length > 0 && (
<ul className="knowledge-list">
{sources.map((source) => (
<SourceRow
key={source.id}
advId={advId}
source={source}
open={openId === source.id}
onToggle={() => setOpenId(openId === source.id ? null : source.id)}
onChange={change}
onDelete={remove}
onError={onError || NO_OP}
/>
))}
</ul>
)}
</div>
)
}
export { KnowledgePanel }
export { KnowledgePanel, ClassLegend, SemanticStatus, CLASSES, CLASS_LABEL }
@@ -1,151 +0,0 @@
// The memory bank panel: the entries retrieval can pull into a prompt.
import { useCallback, useEffect, useState } from 'react'
import { api } from '../../../api'
import { AutoTextarea } from '../../../components'
function MemoryRow({ memory, onChange, onDelete }) {
const [editText, setEditText] = useState(null)
const save = () => {
const text = editText.trim()
setEditText(null)
if (text && text !== memory.text) onChange({ text })
}
return (
<div className={`memory-row ${memory.forgotten ? 'forgotten' : ''}`}>
{editText !== null ? (
<div className="action-edit" style={{ margin: 0 }}>
<AutoTextarea autoFocus value={editText} rows={3}
onChange={(e) => setEditText(e.target.value)} />
<div className="edit-buttons">
<button className="primary" onClick={save}>Save</button>
<button onClick={() => setEditText(null)}>Cancel</button>
</div>
</div>
) : (
<>
<div className="memory-text">{memory.text}</div>
<div className="memory-meta">
{memory.pinned && <span className="memory-badge">📌 pinned</span>}
{memory.forgotten && <span className="memory-badge">forgotten</span>}
{!memory.embedded && !memory.forgotten && (
<span className="memory-badge" title="Embedded on the next turn">not embedded yet</span>
)}
{memory.source_start != null && (
<span className="dim">actions {memory.source_start}–{memory.source_end}</span>
)}
<span className="dim">used {memory.use_count}×</span>
<span className="action-tools">
<button title={memory.pinned ? 'Unpin' : 'Pin (always included in context)'}
onClick={() => onChange({ pinned: !memory.pinned })}>📌</button>
{memory.forgotten && (
<button title="Restore this memory"
onClick={() => onChange({ forgotten: false })}>↩</button>
)}
<button title="Edit" onClick={() => setEditText(memory.text)}>✎</button>
<button title="Delete" onClick={onDelete}>✕</button>
</span>
</div>
</>
)}
</div>
)
}
function MemoryPanel({ adventure, setAdventure, refreshKey }) {
const [memories, setMemories] = useState(null)
const [newText, setNewText] = useState('')
const load = useCallback(() => {
api.listMemories(adventure.id).then(setMemories).catch(() => setMemories([]))
}, [adventure.id])
// Summarization runs in the background after a turn, so also refresh shortly after.
useEffect(() => {
load()
const timer = setTimeout(load, 4000)
return () => clearTimeout(timer)
}, [load, refreshKey])
const setFlag = async (field, value) => {
setAdventure({ ...adventure, [field]: value })
await api.updateAdventure(adventure.id, { [field]: value })
}
const change = async (memory, data) => {
const updated = await api.updateMemory(adventure.id, memory.id, data)
setMemories((prev) => prev.map((m) => (m.id === memory.id ? updated : m)))
}
const remove = async (memory) => {
await api.deleteMemory(adventure.id, memory.id)
setMemories((prev) => prev.filter((m) => m.id !== memory.id))
}
const add = async () => {
const text = newText.trim()
if (!text) return
setNewText('')
const memory = await api.createMemory(adventure.id, text)
setMemories((prev) => [...prev, memory])
}
const active = memories?.filter((m) => !m.forgotten) ?? []
const forgotten = memories?.filter((m) => m.forgotten) ?? []
return (
<div>
<label className="script-attach">
<input type="checkbox" checked={adventure.auto_summarize}
onChange={(e) => setFlag('auto_summarize', e.target.checked)} />
<span>Auto-summarization</span>
<span className="dim">memories every 6 actions, Story Summary every 15</span>
</label>
<label className="script-attach">
<input type="checkbox" checked={adventure.memory_bank_enabled}
onChange={(e) => setFlag('memory_bank_enabled', e.target.checked)} />
<span>Memory Bank</span>
<span className="dim">retrieve relevant memories into context (needs an embedding model)</span>
</label>
<div className="page-header" style={{ marginTop: 18 }}>
<h3 style={{ margin: 0 }}>Memories {memories && `(${active.length})`}</h3>
<button onClick={load}>Refresh</button>
</div>
{!memories && <div className="empty" style={{ padding: '12px 0' }}>Loading…</div>}
{memories && active.length === 0 && (
<div className="empty" style={{ padding: '12px 0' }}>
No memories yet. They are generated automatically as you play (from action 12
onward), or add one below.
</div>
)}
{active.map((m) => (
<MemoryRow key={m.id} memory={m}
onChange={(data) => change(m, data)} onDelete={() => remove(m)} />
))}
<div className="input-bar" style={{ marginTop: 10 }}>
<input type="text" value={newText} placeholder="Add a memory manually…"
onChange={(e) => setNewText(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') add() }} />
<button onClick={add}>+ Add</button>
</div>
{forgotten.length > 0 && (
<>
<div className="page-header" style={{ marginTop: 18 }}>
<h3 style={{ margin: 0 }}>Forgotten ({forgotten.length})</h3>
</div>
{forgotten.map((m) => (
<MemoryRow key={m.id} memory={m}
onChange={(data) => change(m, data)} onDelete={() => remove(m)} />
))}
</>
)}
</div>
)
}
export { MemoryPanel }
@@ -1,156 +0,0 @@
// The plot panel: the adventure's own copy of the scenario text and cards.
import { useState } from 'react'
import { api } from '../../../api'
import { Field, StoryCardRow, downloadJSON, pickJSONFile, useToast } from '../../../components'
import { useDebouncedSave } from '../../../hooks/useDebouncedSave'
import { RefreshModal } from '../RefreshModal'
function PlotPanel({ adventure, setAdventure, onWorldStateChanged }) {
const toast = useToast()
const [plan, setPlan] = useState(null) // non-null while the modal is open
const [planning, setPlanning] = useState(false)
const debounceSave = useDebouncedSave()
const setField = (field, value) => {
setAdventure({ ...adventure, [field]: value })
debounceSave(field, () => api.updateAdventure(adventure.id, { [field]: value }))
}
const addCard = async () => {
const card = await api.createStoryCard({ adventure_id: adventure.id })
setAdventure({ ...adventure, story_cards: [...adventure.story_cards, card] })
}
const updateCard = (card) => {
setAdventure({
...adventure,
story_cards: adventure.story_cards.map((c) => (c.id === card.id ? card : c)),
})
debounceSave(`card-${card.id}`, () => {
api.updateStoryCard(card.id, {
name: card.name, type: card.type, keys: card.keys, entry: card.entry, notes: card.notes,
})
})
}
const deleteCard = async (cardId) => {
await api.deleteStoryCard(cardId)
setAdventure({
...adventure,
story_cards: adventure.story_cards.filter((c) => c.id !== cardId),
})
}
const exportCards = async () => {
const cards = await api.exportStoryCards({ adventure_id: adventure.id })
downloadJSON(cards, `${(adventure.title || 'adventure').replace(/\W+/g, '-')}-cards.json`)
}
const importCards = async () => {
try {
const parsed = await pickJSONFile()
const cards = Array.isArray(parsed) ? parsed : (parsed.cards || parsed.storyCards)
if (!Array.isArray(cards)) return toast('Expected a JSON array of story cards.', 'error')
const created = await api.importStoryCards({ adventure_id: adventure.id, cards })
setAdventure({ ...adventure, story_cards: [...adventure.story_cards, ...created] })
} catch (err) {
toast(err.message, 'error')
}
}
// Ask the server what a refresh would do, then let the player confirm it.
const openRefresh = async () => {
setPlanning(true)
try {
setPlan(await api.previewRefresh(adventure.id))
} catch (err) {
// 404 = the scenario was deleted or unshared; there's nothing to sync to.
toast(err.message, 'error')
} finally {
setPlanning(false)
}
}
const applyRefresh = async (placeholders) => {
try {
const updated = await api.refreshFromScenario(adventure.id, placeholders)
setAdventure(updated)
setPlan(null)
onWorldStateChanged?.()
toast('Updated from scenario.')
} catch (err) {
toast(err.message, 'error')
}
}
return (
<div>
{adventure.scenario_id != null && (
<div className="plot-source">
<span className="dim">
Copied from its scenario when the adventure began; later scenario edits don't
reach it on their own.
</span>
<button className="linklike" onClick={openRefresh} disabled={planning}
title="Replace this adventure's plot text and scenario story cards with the scenario's current content">
{planning ? 'Checking…' : '⟳ Update from scenario'}
</button>
</div>
)}
{plan && (
<RefreshModal plan={plan} onConfirm={applyRefresh} onCancel={() => setPlan(null)} />
)}
{/* Phase 18. The persona sits above the plot text because it is the one
section that describes the player rather than the world, and because
it is the first thing a reader looks for when they want to rename
their character. It is edited only here: the world-state drawer shows
the name as a heading but does not offer a second way to change it. */}
<div className="persona-block">
<div className="persona-row">
<Field label="Character Name" value={adventure.persona_name}
onChange={(v) => setField('persona_name', v)}
maxLength={80} placeholder="Kaelen" />
<Field label="Pronouns" value={adventure.persona_pronouns}
onChange={(v) => setField('persona_pronouns', v)}
maxLength={40} placeholder="they/them" />
</div>
<Field label="Character Description" value={adventure.persona_desc}
onChange={(v) => setField('persona_desc', v)} textarea rows={3}
placeholder="Who you are playing as. The AI never rewrites this — only you can." />
</div>
<Field label="Plot Essentials (Memory)" value={adventure.memory}
onChange={(v) => setField('memory', v)} textarea
placeholder="Key facts the AI should always remember." />
<Field label="Author's Note" value={adventure.authors_note}
onChange={(v) => setField('authors_note', v)} textarea rows={2}
placeholder="Style/theme guidance, injected near the end of context." />
<Field label="AI Instructions" value={adventure.ai_instructions}
onChange={(v) => setField('ai_instructions', v)} textarea rows={2}
placeholder="Behavioral guidance for the model." />
<Field label="Story Summary" value={adventure.story_summary}
onChange={(v) => setField('story_summary', v)} textarea
placeholder="Running summary of events so far. Updated automatically every 15 actions when auto-summarization is on; your edits are kept as the base for the next update." />
<div className="page-header" style={{ marginTop: 18 }}>
<h3 style={{ margin: 0 }}>Story Cards</h3>
<div style={{ display: 'flex', gap: 6 }}>
<button onClick={exportCards} disabled={adventure.story_cards.length === 0}>Export</button>
<button onClick={importCards}>Import</button>
<button onClick={addCard}>+ Add</button>
</div>
</div>
{adventure.story_cards.length === 0 && (
<div className="empty" style={{ padding: '12px 0' }}>No story cards yet.</div>
)}
{adventure.story_cards.map((card) => (
<StoryCardRow key={card.id} card={card}
onChange={updateCard} onDelete={() => deleteCard(card.id)} />
))}
</div>
)
}
export { PlotPanel }
@@ -0,0 +1,490 @@
/* The panels: knowledge classification and status, the context inspector's
* sections, Save Point dialogs, and the failure presentation.
*
* §30's remaining named list. These are rendering tests against the shapes the
* real API returns — each fixture below is the shape observed from a running
* server, not one invented to match the component.
*/
import { screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from '../../../api'
import { classifyError, KIND } from '../../../errors'
import { FailureNotice } from '../FailureNotice'
import { ContextPanel } from './ContextPanel'
import { CampaignSettingsPanel } from './CampaignSettingsPanel'
import { KnowledgePanel, SemanticStatus } from './KnowledgePanel'
import { SavePointPanel } from './SavePointPanel'
import { renderWith } from '../../../test/helpers'
const SOURCE = {
id: 7,
title: 'canon',
original_filename: 'canon.md',
classification: 'canon',
enabled: true,
visibility: 'normal',
always_include: false,
imported_at: '2026-09-06T10:00:00',
byte_size: 2048,
media_type: 'text/markdown',
chunk_count: 4,
embedded_count: 4,
parser_version: 1,
chunking_version: 1,
content_hash: 'a'.repeat(64),
index_state: 'ok',
embed_state: 'ok',
index_detail: '',
embed_detail: '',
}
const STATUS = {
sources: 1,
enabled_sources: 1,
semantic_enabled: true,
semantic_calibrated: true,
embedding_model: 'nomic-embed-text',
calibrated_models: ['nomic-embed-text'],
semantic_note: '',
pending_embeddings: 0,
failed_index: [],
failed_embedding: [],
}
beforeEach(() => { vi.restoreAllMocks() })
describe('knowledge classification (§48)', () => {
beforeEach(() => {
vi.spyOn(api, 'listKnowledge').mockResolvedValue([SOURCE])
vi.spyOn(api, 'getKnowledgeStatus').mockResolvedValue(STATUS)
})
it('shows each source with its class, filename and import date', async () => {
await renderWith(<KnowledgePanel advId="1" refreshKey="x" />)
const row = document.querySelector('[data-source-id="7"]')
expect(row).toHaveAttribute('data-classification', 'canon')
// The row carries "Canon" twice — as the class tag and as the selected
// option in the reclassify control — so this asks for the tag specifically.
expect(row.querySelector('.class-tag')).toHaveTextContent('Canon')
expect(within(row).getByText(/canon\.md/)).toBeInTheDocument()
expect(within(row).getByText(/imported/)).toBeInTheDocument()
})
it('explains what each class means rather than relying on an icon', async () => {
vi.spyOn(api, 'listKnowledge').mockResolvedValue([])
await renderWith(<KnowledgePanel advId="1" refreshKey="x" />)
// Present on the import form and again in the empty-state legend; the
// assertion is that a reader meets the explanation, not that it is unique.
expect(screen.getAllByText(/Authoritative truth for this campaign/).length)
.toBeGreaterThan(0)
expect(screen.getAllByText(/does not establish story truth/).length).toBeGreaterThan(0)
expect(screen.getAllByText(/Creative and style influence only/).length).toBeGreaterThan(0)
})
it('marks a disabled source as not in use', async () => {
vi.spyOn(api, 'listKnowledge').mockResolvedValue([{ ...SOURCE, enabled: false }])
await renderWith(<KnowledgePanel advId="1" refreshKey="x" />)
expect(screen.getByText('not in use')).toBeInTheDocument()
})
it('marks a narrator-only source', async () => {
vi.spyOn(api, 'listKnowledge').mockResolvedValue([{ ...SOURCE, visibility: 'hidden' }])
await renderWith(<KnowledgePanel advId="1" refreshKey="x" />)
expect(screen.getByText('narrator only')).toBeInTheDocument()
})
it('explains what deleting does and does not do before doing it', async () => {
const user = userEvent.setup()
const remove = vi.spyOn(api, 'deleteKnowledgeSource').mockResolvedValue(null)
await renderWith(<KnowledgePanel advId="1" refreshKey="x" />)
await user.click(screen.getByRole('button', { name: 'Delete' }))
const dialog = screen.getByRole('dialog')
expect(within(dialog).getByText(/Turns that already used it are unchanged/))
.toBeInTheDocument()
expect(remove).not.toHaveBeenCalled()
await user.click(within(dialog).getByRole('button', { name: 'Delete this source' }))
expect(remove).toHaveBeenCalledWith('1', 7)
})
})
describe('semantic status (§22)', () => {
it('says both kinds of search are on when they are', async () => {
await renderWith(<SemanticStatus status={STATUS} />)
const el = screen.getByTestId('semantic-status')
expect(el).toHaveAttribute('data-state', 'on')
expect(el).toHaveTextContent(/keyword and by meaning/)
})
it('presents no embedding model as a supported setup, not a failure', async () => {
await renderWith(<SemanticStatus status={{
...STATUS, semantic_enabled: false, embedding_model: '',
}} />)
const el = screen.getByTestId('semantic-status')
expect(el).toHaveAttribute('data-state', 'lexical-only')
expect(el).toHaveTextContent(/supported setup/)
// Named as the Settings field is named, not as the subsystem is named:
// pointing a reader at an "embedding model" they will not find is worse
// than saying nothing (§9 terminology audit).
expect(el).toHaveTextContent(/model for meaning-based search/i)
expect(el.textContent).not.toMatch(/\bembedding\b/i)
})
it('explains an uncalibrated model instead of showing a mysterious failure', async () => {
await renderWith(<SemanticStatus status={{
...STATUS,
semantic_enabled: false,
semantic_calibrated: false,
embedding_model: 'mxbai-embed-large',
}} />)
const el = screen.getByTestId('semantic-status')
expect(el).toHaveAttribute('data-state', 'uncalibrated')
expect(el).toHaveTextContent(/has not been measured/)
expect(el).toHaveTextContent(/Keyword search is unaffected/)
expect(el).toHaveTextContent(/meaning-based search/i)
expect(el.textContent).not.toMatch(/\bembedding\b/i)
})
it('exposes no threshold and no tuning control', async () => {
const { container } = await renderWith(<SemanticStatus status={{
...STATUS, semantic_enabled: false, semantic_calibrated: false,
embedding_model: 'mxbai-embed-large',
}} />)
expect(container.textContent).not.toContain('0.58')
expect(container.querySelector('input[type="range"]')).toBeNull()
})
})
const REPORT = {
tokens: { total: 1200, budget: 8000, output_reserve: 400 },
history: { included: 4, total: 4, oldest_truncated: false },
sections: [
{ label: 'narrator', text: 'You are the narrator.', tokens: 60 },
{ label: 'imported_canon', text: 'The abbey lies north.', tokens: 40 },
],
cards: [],
settings: { model: 'qwen2.5:3b-instruct', temperature: 0.2, max_output_tokens: 400 },
derived: [],
memories: null,
summary: null,
knowledge: {
generated: 4,
rejected: 3,
spent: 40,
budget: 800,
terms: ['abbey', 'crypt'],
semantic_note: '',
used: [{
chunk_id: 11,
source_id: 7,
filename: 'canon.md',
title: 'canon',
classification: 'canon',
visibility: 'normal',
heading_path: 'Old Abbey',
chunk_index: 0,
mode: 'hybrid',
cosine: 0.71,
score: 0.8,
prompt_tokens: 40,
always_include: false,
text: 'The Old Abbey lies five miles north of Westhaven.',
}],
suppressed: [],
dropped: [],
},
}
describe('context inspector (§23, §55)', () => {
beforeEach(() => {
vi.spyOn(api, 'getAdventureContext').mockResolvedValue(REPORT)
})
it('renders the readable sections, not a raw prompt, by default', async () => {
await renderWith(<ContextPanel advId="1" refreshKey="x" />)
expect(screen.getByTestId('ctx-tokens')).toBeInTheDocument()
expect(screen.getByTestId('ctx-knowledge')).toBeInTheDocument()
expect(screen.getByTestId('ctx-memories')).toBeInTheDocument()
expect(screen.getByTestId('ctx-summary')).toBeInTheDocument()
expect(screen.getByTestId('ctx-settings')).toBeInTheDocument()
// The exact prompt is present but closed.
expect(screen.getByTestId('ctx-raw')).not.toHaveAttribute('open')
})
it('shows the token budget with the reply reserve and the model limit', async () => {
await renderWith(<ContextPanel advId="1" refreshKey="x" />)
const tokens = screen.getByTestId('ctx-tokens')
expect(tokens).toHaveTextContent('1,200')
expect(tokens).toHaveTextContent('400')
expect(tokens).toHaveTextContent('8,000')
})
it('shows a retrieved passage with its source, class, heading and score', async () => {
await renderWith(<ContextPanel advId="1" refreshKey="x" />)
const row = document.querySelector('[data-chunk-id="11"]')
expect(row).toHaveAttribute('data-classification', 'canon')
expect(within(row).getByText('Canon')).toBeInTheDocument()
expect(within(row).getByText('Old Abbey')).toBeInTheDocument()
expect(within(row).getByText(/0\.71/)).toBeInTheDocument()
expect(within(row).getByText(/The Old Abbey lies five miles north/)).toBeInTheDocument()
})
it('opens the source it came from when the filename is clicked (§57)', async () => {
const user = userEvent.setup()
const onOpenSource = vi.fn()
await renderWith(<ContextPanel advId="1" refreshKey="x" onOpenSource={onOpenSource} />)
await user.click(screen.getByRole('button', { name: 'canon.md' }))
expect(onOpenSource).toHaveBeenCalledWith(7)
})
it('says plainly when nothing was relevant enough', async () => {
vi.spyOn(api, 'getAdventureContext').mockResolvedValue({
...REPORT,
knowledge: { ...REPORT.knowledge, used: [], rejected: 4 },
})
await renderWith(<ContextPanel advId="1" refreshKey="x" />)
expect(screen.getByTestId('ctx-knowledge-none'))
.toHaveTextContent(/none were close enough to this scene/)
})
it('surfaces failing background work', async () => {
vi.spyOn(api, 'getAdventureContext').mockResolvedValue({
...REPORT,
derived: [{ kind: 'summary', status: 'failed', failures: 3, detail: 'timeout' }],
})
await renderWith(<ContextPanel advId="1" refreshKey="x" />)
expect(screen.getByTestId('ctx-derived-failing')).toHaveTextContent(/summary/)
})
})
describe('narrator-only passages (§19)', () => {
const hiddenReport = {
...REPORT,
knowledge: {
...REPORT.knowledge,
used: [{
...REPORT.knowledge.used[0],
visibility: 'hidden',
text: 'The silver key opens the abbey crypt.',
}],
},
}
it('withholds the text until the reader asks for it', async () => {
vi.spyOn(api, 'getAdventureContext').mockResolvedValue(hiddenReport)
await renderWith(<ContextPanel advId="1" refreshKey="x" />)
expect(screen.queryByText(/opens the abbey crypt/)).toBeNull()
expect(screen.getByText(/Hidden — this passage is narrator-only/)).toBeInTheDocument()
})
it('warns what turning it on will reveal', async () => {
vi.spyOn(api, 'getAdventureContext').mockResolvedValue(hiddenReport)
await renderWith(<ContextPanel advId="1" refreshKey="x" />)
expect(screen.getByTestId('reveal-hidden'))
.toHaveTextContent(/reveal secrets you marked for the narrator alone/)
})
it('reveals it only on an explicit choice', async () => {
const user = userEvent.setup()
vi.spyOn(api, 'getAdventureContext').mockResolvedValue(hiddenReport)
await renderWith(<ContextPanel advId="1" refreshKey="x" />)
await user.click(within(screen.getByTestId('reveal-hidden')).getByRole('checkbox'))
expect(screen.getByText(/opens the abbey crypt/)).toBeInTheDocument()
})
it('withholds the assembled prompt too, not just the passage row', async () => {
// The passage rows hide the secret one by one; the assembled prompt at the
// bottom contains the same text verbatim. A closed <details> still holds
// its contents in the DOM, where find-in-page reaches them — so hiding it
// visually would be the appearance of a guard rather than a guard. Found by
// the sentinel browser test, which checked innerHTML rather than innerText.
vi.spyOn(api, 'getAdventureContext').mockResolvedValue({
...hiddenReport,
sections: [
{ label: 'narrator', text: 'You are the narrator.', tokens: 60 },
{ label: 'imported_canon', text: 'The silver key opens the abbey crypt.', tokens: 40 },
],
})
const { container } = await renderWith(<ContextPanel advId="1" refreshKey="x" />)
expect(screen.getByTestId('ctx-raw-withheld')).toBeInTheDocument()
// Not present anywhere in the markup, not merely invisible.
expect(container.innerHTML).not.toContain('opens the abbey crypt')
})
it('shows the assembled prompt once the reader reveals', async () => {
const user = userEvent.setup()
vi.spyOn(api, 'getAdventureContext').mockResolvedValue({
...hiddenReport,
sections: [
{ label: 'imported_canon', text: 'The silver key opens the abbey crypt.', tokens: 40 },
],
})
const { container } = await renderWith(<ContextPanel advId="1" refreshKey="x" />)
await user.click(within(screen.getByTestId('reveal-hidden')).getByRole('checkbox'))
expect(screen.queryByTestId('ctx-raw-withheld')).toBeNull()
expect(container.innerHTML).toContain('opens the abbey crypt')
})
it('leaves the assembled prompt readable when nothing is narrator-only', async () => {
vi.spyOn(api, 'getAdventureContext').mockResolvedValue(REPORT)
const { container } = await renderWith(<ContextPanel advId="1" refreshKey="x" />)
expect(screen.queryByTestId('ctx-raw-withheld')).toBeNull()
expect(container.innerHTML).toContain('The abbey lies north')
})
it('offers no reveal control when nothing narrator-only was retrieved', async () => {
vi.spyOn(api, 'getAdventureContext').mockResolvedValue(REPORT)
await renderWith(<ContextPanel advId="1" refreshKey="x" />)
expect(screen.queryByTestId('reveal-hidden')).toBeNull()
})
})
describe('Save Points (§26, §27)', () => {
const POINT = {
id: 3, name: 'Before the abbey', note: '', turn: 12, on_path: true, resolved: true,
}
beforeEach(() => {
vi.spyOn(api, 'listCheckpoints').mockResolvedValue([POINT])
})
it('says that later story is kept before restoring', async () => {
const user = userEvent.setup()
await renderWith(<SavePointPanel advId="1" refreshKey="x" onRestored={vi.fn()} onError={vi.fn()} />)
await user.click(screen.getByRole('button', { name: 'Restore' }))
expect(screen.getByText(/Everything you wrote after it is kept/)).toBeInTheDocument()
})
it('says that deleting a Save Point deletes no story', async () => {
const user = userEvent.setup()
await renderWith(<SavePointPanel advId="1" refreshKey="x" onRestored={vi.fn()} onError={vi.fn()} />)
await user.click(screen.getByRole('button', { name: 'Delete' }))
expect(screen.getByText(/does not delete any of the story/)).toBeInTheDocument()
})
it('counts in moments, not turns (§25)', async () => {
await renderWith(<SavePointPanel advId="1" refreshKey="x" onRestored={vi.fn()} onError={vi.fn()} />)
expect(screen.getByText(/Moment 12/)).toBeInTheDocument()
})
it('uses no branch vocabulary', async () => {
const { container } = await renderWith(
<SavePointPanel advId="1" refreshKey="x" onRestored={vi.fn()} onError={vi.fn()} />,
)
const text = container.textContent.toLowerCase()
for (const word of ['branch', 'fork', 'node', 'head']) {
expect(text).not.toContain(word)
}
})
})
describe('failure presentation (§12, §71)', () => {
it('tells the five kinds apart', () => {
expect(classifyError('No model configured — set one in Settings.').kind).toBe(KIND.MODEL)
expect(classifyError('Could not connect to http://x — is the AI server running?').kind)
.toBe(KIND.MODEL)
expect(classifyError('The AI endpoint timed out.').kind).toBe(KIND.MODEL)
expect(classifyError('state validation failed: unknown entity').kind).toBe(KIND.STATE)
expect(classifyError('knowledge index rebuild failed').kind).toBe(KIND.KNOWLEDGE)
expect(classifyError('database is locked').kind).toBe(KIND.SERVER)
})
it('never reduces an unrecognised failure to "something went wrong"', () => {
const f = classifyError('the model produced eleven hats')
expect(f.kind).toBe(KIND.GENERATION)
// The server's own words survive, and Retry is still offered.
expect(f.detail).toBe('the model produced eleven hats')
expect(f.retryable).toBe(true)
})
it('reassures that nothing typed was lost (A05)', async () => {
await renderWith(
<FailureNotice
failure={classifyError('The AI endpoint timed out.')}
partial={null}
onRetry={vi.fn()}
onDismiss={vi.fn()}
/>,
)
expect(screen.getByTestId('failure')).toHaveAttribute('data-kind', 'model')
expect(screen.getByText(/what you typed is still in the box/)).toBeInTheDocument()
})
it('labels partial prose as not kept, rather than showing it as story', async () => {
await renderWith(
<FailureNotice
failure={classifyError('boom')}
partial="The tavern door swung"
onRetry={vi.fn()}
onDismiss={vi.fn()}
/>,
)
expect(screen.getByText(/this was not kept/)).toBeInTheDocument()
})
it('keeps the raw detail available but folded away (§72)', async () => {
await renderWith(
<FailureNotice
failure={classifyError('HTTP 500: kaboom')}
partial={null}
onRetry={vi.fn()}
onDismiss={vi.fn()}
/>,
)
const details = screen.getByText('Show technical details').closest('details')
expect(details).not.toHaveAttribute('open')
expect(details).toHaveTextContent('HTTP 500: kaboom')
})
})
describe('campaign canon after play (§6)', () => {
const campaign = {
id: 1,
title: 'Continuity Test',
ai_instructions: '',
canon_rules: ['Resurrection is impossible.'],
persona_name: 'Aldric',
persona_desc: '',
}
it('says nothing extra while the campaign has no story yet', async () => {
await renderWith(
<CampaignSettingsPanel
adventure={campaign} setAdventure={vi.fn()} onError={vi.fn()} moments={0} />,
)
expect(screen.queryByTestId('canon-history-warning')).toBeNull()
})
it('warns once a story exists, and says what the edit does not reach', async () => {
// Canon is the highest authority in a campaign. Changing it after play is a
// configuration change, not a retroactive one, and the reader has to be
// told which — otherwise a post-play edit passes silently.
await renderWith(
<CampaignSettingsPanel
adventure={campaign} setAdventure={vi.fn()} onError={vi.fn()} moments={12} />,
)
const warning = screen.getByTestId('canon-history-warning')
expect(warning).toHaveTextContent('12 moments')
expect(warning).toHaveTextContent(/from here\s*on/)
expect(warning).toHaveTextContent(/already written stays exactly as it is/)
// And it points at where the per-turn record can be seen.
expect(warning).toHaveTextContent(/Inspect context/)
})
it('edits the sentence list, not the stored document', async () => {
const user = userEvent.setup()
const update = vi.spyOn(api, 'updateAdventure').mockResolvedValue(campaign)
await renderWith(
<CampaignSettingsPanel
adventure={campaign} setAdventure={vi.fn()} onError={vi.fn()} moments={3} />,
)
const box = screen.getByLabelText('Canon')
await user.clear(box)
await user.type(box, 'One rule.{Enter}Another rule.')
await user.click(screen.getByRole('button', { name: 'Save changes' }))
expect(update).toHaveBeenCalledWith(1, expect.objectContaining({
canon_rules: ['One rule.', 'Another rule.'],
}))
})
})
-395
View File
@@ -1,395 +0,0 @@
import { useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { api } from '../api'
import { Field, StoryCardRow, downloadJSON, pickJSONFile, useToast } from '../components'
import { useDebouncedSave } from '../hooks/useDebouncedSave'
import ArtPicker from '../ArtPicker'
import SchemaEditor, { NpcEditor, addNpc } from '../SchemaEditor'
export default function ScenarioEditor() {
const { id } = useParams()
const navigate = useNavigate()
const [scenario, setScenario] = useState(null)
const [status, setStatus] = useState('')
// Raw text buffer for the stat_schema JSON editor + a live parse error.
const [schemaText, setSchemaText] = useState('')
const [schemaError, setSchemaError] = useState('')
// Last successfully-parsed schema (drives the form editor + preview).
const [parsedSchema, setParsedSchema] = useState(null)
const [schemaView, setSchemaView] = useState('form') // 'form' | 'json'
const toast = useToast()
const debounceSave = useDebouncedSave()
useEffect(() => {
api.getScenario(id).then((s) => {
setScenario(s)
setSchemaText(s.stat_schema ? JSON.stringify(s.stat_schema, null, 2) : '')
setParsedSchema(s.stat_schema || null)
}).catch(() => navigate('/scenarios'))
}, [id, navigate])
const setField = (field, value) => {
const next = { ...scenario, [field]: value }
setScenario(next)
debounceSave(field, async () => {
await api.updateScenario(id, { [field]: value })
setStatus('Saved')
setTimeout(() => setStatus(''), 1500)
})
}
// Several fields at once, in one PATCH — the art picker sets `image` and
// `icon` together (choosing one clears the other, and they must not race).
const setFields = (patch) => {
setScenario((prev) => ({ ...prev, ...patch }))
debounceSave('art', async () => {
await api.updateScenario(id, patch)
setStatus('Saved')
setTimeout(() => setStatus(''), 1500)
})
}
// JSON view: typing only updates the text buffer; nothing is parsed or saved
// until the user clicks Save (so a half-typed edit doesn't spam errors).
const onJsonText = (text) => {
setSchemaText(text)
if (schemaError) setSchemaError('')
}
// Parse the JSON box on demand: apply + save if valid, else show the error.
const commitJson = () => {
const trimmed = schemaText.trim()
if (!trimmed) {
setSchemaError('')
setParsedSchema(null)
saveSchema(null)
return
}
let parsed
try {
parsed = JSON.parse(trimmed)
} catch (err) {
setSchemaError(`Invalid JSON: ${err.message}`)
return
}
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
setSchemaError('The schema must be a JSON object (e.g. { "player": { … } }).')
return
}
setSchemaError('')
setParsedSchema(parsed)
setSchemaText(JSON.stringify(parsed, null, 2)) // normalize / pretty-print
saveSchema(parsed)
}
const saveSchema = async (parsed) => {
await api.updateScenario(id, { stat_schema: parsed })
setScenario((s) => ({ ...s, stat_schema: parsed }))
setStatus('Saved')
setTimeout(() => setStatus(''), 1500)
}
// Edits from the form editor: keep the JSON text in sync and save. An empty
// schema clears the RPG layer (null).
const applySchema = (next) => {
const cleaned = next && Object.keys(next).length ? next : null
setParsedSchema(cleaned)
setSchemaText(cleaned ? JSON.stringify(cleaned, null, 2) : '')
setSchemaError('')
debounceSave('stat_schema', () => saveSchema(cleaned))
}
// NPCs live in the same stat_schema, but are edited in their own page
// section — so this is just the `npcs` slice of the same save path.
const setNpcs = (npcs) => {
const next = { ...(parsedSchema || {}) }
if (npcs && Object.keys(npcs).length) next.npcs = npcs
else delete next.npcs
applySchema(next)
}
const addCard = async () => {
const card = await api.createStoryCard({ scenario_id: Number(id) })
setScenario({ ...scenario, story_cards: [...scenario.story_cards, card] })
}
const updateCard = (card) => {
setScenario({
...scenario,
story_cards: scenario.story_cards.map((c) => (c.id === card.id ? card : c)),
})
debounceSave(`card-${card.id}`, () => {
api.updateStoryCard(card.id, {
name: card.name, type: card.type, keys: card.keys, entry: card.entry, notes: card.notes,
})
})
}
const deleteCard = async (cardId) => {
await api.deleteStoryCard(cardId)
setScenario({ ...scenario, story_cards: scenario.story_cards.filter((c) => c.id !== cardId) })
}
const exportCards = async () => {
const cards = await api.exportStoryCards({ scenario_id: id })
downloadJSON(cards, `${scenario.title.replace(/\W+/g, '-')}-cards.json`)
}
const importCards = async () => {
try {
const parsed = await pickJSONFile()
const cards = Array.isArray(parsed) ? parsed : (parsed.cards || parsed.storyCards)
if (!Array.isArray(cards)) return toast('Expected a JSON array of story cards.', 'error')
const created = await api.importStoryCards({ scenario_id: Number(id), cards })
setScenario({ ...scenario, story_cards: [...scenario.story_cards, ...created] })
} catch (err) {
toast(err.message, 'error')
}
}
const exportScenario = async () => {
downloadJSON(await api.exportScenario(id), `${scenario.title.replace(/\W+/g, '-')}.json`)
}
const deleteScenario = async () => {
if (!confirm('Delete this scenario permanently?')) return
await api.deleteScenario(id)
navigate('/scenarios')
}
const play = async () => {
const adv = await api.createAdventure({ scenario_id: Number(id) })
navigate(`/play/${adv.id}`)
}
if (!scenario) return null
// Shared demo scenarios (Phase 8) are visible to everyone but owned by no
// one; the backend rejects edits, so present them read-only.
const readOnly = !!scenario.is_public
return (
<div className="page">
<div className="page-header">
<h1>{readOnly ? 'Scenario (read-only)' : 'Edit Scenario'}</h1>
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<span style={{ color: 'var(--text-dim)', fontSize: '0.85rem' }}>{status}</span>
<button onClick={exportScenario}>Export</button>
{!readOnly && <button className="danger" onClick={deleteScenario}>Delete</button>}
<button className="primary" onClick={play}>Play</button>
</div>
</div>
{readOnly && (
<div className="demo-banner">
This is a shared demo scenario — it can’t be edited, but you can hit
<strong> Play</strong> to start your own adventure from it, or <strong>Export</strong> and
re-import it as your own copy.
</div>
)}
<fieldset disabled={readOnly} style={{ border: 'none', padding: 0, margin: 0, minWidth: 0 }}>
<Field label="Title" value={scenario.title} onChange={(v) => setField('title', v)} />
<Field label="Description" value={scenario.description} onChange={(v) => setField('description', v)}
textarea placeholder="Shown in the scenario list; not sent to the AI." />
<ArtPicker
title={scenario.title}
image={scenario.image}
icon={scenario.icon}
disabled={readOnly}
onChange={setFields}
/>
<Field label="Opening Prompt" value={scenario.prompt} onChange={(v) => setField('prompt', v)}
textarea rows={6} placeholder="The opening story text. Supports ${placeholders} (Phase 5)." />
<Field label="Plot Essentials (Memory)" value={scenario.memory} onChange={(v) => setField('memory', v)}
textarea placeholder="Key facts the AI should always remember." />
<Field label="Author's Note" value={scenario.authors_note} onChange={(v) => setField('authors_note', v)}
textarea rows={2} placeholder="Style/theme guidance, injected near the end of context." />
<Field label="AI Instructions" value={scenario.ai_instructions} onChange={(v) => setField('ai_instructions', v)}
textarea rows={2} placeholder="Behavioral guidance for the model (always included)." />
<Field label="Tags" value={scenario.tags} onChange={(v) => setField('tags', v)} placeholder="fantasy, mystery" />
<div className="page-header" style={{ marginTop: 28 }}>
<h2 style={{ margin: 0, fontFamily: 'Georgia, serif', fontSize: '1.2rem' }}>Story Cards</h2>
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={exportCards} disabled={scenario.story_cards.length === 0}>Export</button>
{!readOnly && <button onClick={importCards}>Import</button>}
<button onClick={addCard}>+ Add Card</button>
</div>
</div>
{scenario.story_cards.length === 0 && (
<div className="empty" style={{ padding: '20px 0' }}>
No story cards. Cards inject their Entry into context when a trigger word appears in the story.
</div>
)}
{scenario.story_cards.map((card) => (
<StoryCardRow key={card.id} card={card}
onChange={updateCard} onDelete={() => deleteCard(card.id)} />
))}
<div className="page-header" style={{ marginTop: 28 }}>
<h2 style={{ margin: 0, fontFamily: 'Georgia, serif', fontSize: '1.2rem' }}>World State (RPG)</h2>
<div className="panel-toggles">
<button type="button" className={schemaView === 'form' ? 'active' : ''}
onClick={() => setSchemaView('form')}>Editor</button>
<button type="button" className={schemaView === 'json' ? 'active' : ''}
onClick={() => setSchemaView('json')}>JSON</button>
</div>
</div>
<p className="dim" style={{ margin: '0 0 10px', fontSize: '0.85rem' }}>
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} />
) : (
<>
<textarea
className="schema-editor"
value={schemaText}
onChange={(e) => onJsonText(e.target.value)}
rows={14}
spellCheck={false}
placeholder={'{\n "player": { "hp": { "min": 0, "max": 100, "initial": 100 } },\n "npcs": {\n "gwen": { "name": "Gwen", "keys": "Gwen, ranger", "desc": "...",\n "stats": { "trust": { "min": -100, "max": 100, "initial": 20 } } }\n },\n "flags": { "has_key": { "desc": "...", "initial": false } },\n "milestones": { "goal": { "desc": "..." } }\n}'}
/>
<div className="schema-json-actions">
<button type="button" className="primary" onClick={commitJson}>Save JSON</button>
{schemaError
? <span className="schema-error">⚠ {schemaError}</span>
: <span className="dim" style={{ fontSize: '0.8rem' }}>
Edits apply when you click Save.
</span>}
</div>
<SchemaPreview schema={parsedSchema} />
</>
)}
<div className="page-header" style={{ marginTop: 28 }}>
<h2 style={{ margin: 0, fontFamily: 'Georgia, serif', fontSize: '1.2rem' }}>Cast &amp; NPCs</h2>
{schemaView === 'form' && (
<button onClick={() => setNpcs(addNpc(parsedSchema?.npcs))}>+ Add NPC</button>
)}
</div>
<p className="dim" style={{ margin: '0 0 10px', fontSize: '0.85rem' }}>
Characters the AI tracks by name. Each has its own trigger words, description and
stats — a dragon’s <em>ferocity</em>, a ranger’s <em>trust</em> — addressed as
<code> npc.&lt;id&gt;.&lt;stat&gt;</code>. Adding one here also creates a story card
for it when an adventure starts.
</p>
{schemaView === 'form' ? (
<NpcEditor npcs={parsedSchema?.npcs || {}} onChange={setNpcs} />
) : (
<div className="empty" style={{ padding: '20px 0' }}>
NPCs are part of the schema JSON above — switch to <strong>Editor</strong> to edit
them as cards.
</div>
)}
</fieldset>
</div>
)
}
// ---- Structured preview of a parsed stat_schema (read-only) ----
function statMeta(def) {
const parts = []
if (def.initial !== undefined) parts.push(`start ${def.initial}`)
if (typeof def.min === 'number' && typeof def.max === 'number') parts.push(`${def.min}–${def.max}`)
if (def.type === 'counter') parts.push('counter')
if (typeof def.max_delta_per_turn === 'number') parts.push(`±${def.max_delta_per_turn}/turn`)
if (def.cooldown) parts.push(`cooldown ${def.cooldown}`)
return parts.join(' · ')
}
function StatDefRow({ name, def }) {
if (!def || typeof def !== 'object') return null
const bands = Array.isArray(def.bands)
? def.bands.filter((b) => Array.isArray(b) && b.length === 3)
.map((b) => `${b[0]}–${b[1]} ${b[2]}`).join(', ')
: null
return (
<div className="schema-stat">
<div className="schema-stat-head">
<span className="schema-stat-name">{name}</span>
<span className="schema-stat-meta">{statMeta(def)}</span>
</div>
{def.desc && <div className="schema-desc">{def.desc}</div>}
{bands && <div className="schema-bands">bands: {bands}</div>}
</div>
)
}
function StatDefGroup({ title, defs }) {
const entries = Object.entries(defs || {}).filter(([, d]) => d && typeof d === 'object')
if (entries.length === 0) return null
return (
<div className="schema-group">
<div className="schema-group-title">{title}</div>
{entries.map(([n, d]) => <StatDefRow key={n} name={n} def={d} />)}
</div>
)
}
function SchemaPreview({ schema }) {
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return null
const world = schema.world || {}
const player = schema.player || {}
const npcs = schema.npcs || {}
const flags = schema.flags || {}
const milestones = schema.milestones || {}
const count = [world, player, npcs, flags, milestones]
.reduce((n, o) => n + Object.keys(o || {}).length, 0)
if (count === 0) return null
return (
<div className="schema-preview">
<div className="schema-preview-title">Parsed structure</div>
<StatDefGroup title="World" defs={world} />
<StatDefGroup title="Player" defs={player} />
{Object.entries(npcs).map(([npcId, npc]) => (
npc && typeof npc === 'object' ? (
<div key={npcId} className="schema-group">
<div className="schema-group-title">
{npc.name || npcId} <span className="schema-npc-id">npc.{npcId}</span>
</div>
{npc.keys && <div className="schema-desc">triggers: {npc.keys}</div>}
{npc.desc && <div className="schema-desc">{npc.desc}</div>}
{Object.entries(npc.stats || {}).map(([n, d]) => (
<StatDefRow key={n} name={n} def={d} />
))}
</div>
) : null
))}
{Object.keys(flags).length > 0 && (
<div className="schema-group">
<div className="schema-group-title">Flags</div>
{Object.entries(flags).map(([fid, f]) => (
<div key={fid} className="schema-stat">
<div className="schema-stat-head">
<span className="schema-stat-name">{fid}</span>
<span className="schema-stat-meta">{f?.initial ? 'on by default' : 'off by default'}</span>
</div>
{f?.desc && <div className="schema-desc">{f.desc}</div>}
</div>
))}
</div>
)}
{Object.keys(milestones).length > 0 && (
<div className="schema-group">
<div className="schema-group-title">Milestones</div>
{Object.entries(milestones).map(([mid, m]) => (
<div key={mid} className="schema-stat">
<div className="schema-stat-head">
<span className="schema-stat-name">{mid}</span>
</div>
{m?.desc && <div className="schema-desc">{m.desc}</div>}
</div>
))}
</div>
)}
</div>
)
}
-182
View File
@@ -1,182 +0,0 @@
import { useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { api } from '../api'
import {
CardSkeleton,
extractPlaceholders,
pickJSONFile,
BeginAdventureModal,
ScenarioArt,
useToast,
} from '../components'
function splitTags(tags, { isPublic = false } = {}) {
const all = (tags || '').split(',').map((t) => t.trim()).filter(Boolean)
// Public scenarios already carry a "demo ✦" badge; the tag would repeat it.
return isPublic ? all.filter((t) => t.toLowerCase() !== 'demo') : all
}
export default function Scenarios() {
const [scenarios, setScenarios] = useState(null)
const [search, setSearch] = useState('')
const [tagFilter, setTagFilter] = useState(null)
const [pending, setPending] = useState(null) // { scenario, names } awaiting placeholder values
const navigate = useNavigate()
const toast = useToast()
useEffect(() => {
api.listScenarios().then(setScenarios).catch(() => setScenarios([]))
}, [])
const allTags = useMemo(() => {
const tags = new Set()
for (const sc of scenarios || []) splitTags(sc.tags).forEach((t) => tags.add(t))
return [...tags].sort()
}, [scenarios])
const visible = useMemo(() => {
if (!scenarios) return null
const q = search.trim().toLowerCase()
return scenarios.filter((sc) => {
if (tagFilter && !splitTags(sc.tags).includes(tagFilter)) return false
if (q && !`${sc.title} ${sc.description} ${sc.tags}`.toLowerCase().includes(q)) return false
return true
})
}, [scenarios, search, tagFilter])
const createScenario = async () => {
const scenario = await api.createScenario({ title: 'New Scenario' })
navigate(`/scenarios/${scenario.id}`)
}
const begin = async (scenarioId, { persona = {}, placeholders = {} } = {}) => {
const adv = await api.createAdventure({
scenario_id: scenarioId,
// A blank adventure has no scenario to take a title from, so name it here
// exactly as the button that starts it did before the modal existed.
title: scenarioId ? null : 'Blank Adventure',
placeholders,
persona_name: persona.name || '',
persona_pronouns: persona.pronouns || '',
persona_desc: persona.desc || '',
})
navigate(`/play/${adv.id}`)
}
const startAdventure = async (e, scenarioId) => {
e.stopPropagation()
const scenario = await api.getScenario(scenarioId)
const names = extractPlaceholders(
scenario.prompt, scenario.memory, scenario.authors_note, scenario.ai_instructions,
// Cards can carry ${placeholders} in trigger keys too, not just entries.
...scenario.story_cards.flatMap((c) => [c.keys, c.entry]),
)
// Always open the modal, even with no placeholders: it is where the
// player names their character.
setPending({ scenario, names })
}
// A blank adventure has no scenario, so there are no placeholders to collect,
// but the player still names their character. `pending.scenario` is null for
// this path, and `begin` is called with no scenario id.
const startBlank = () => setPending({ scenario: null, names: [] })
return (
<div className="page">
<div className="page-header">
<h1>Scenarios</h1>
<div style={{ display: 'flex', gap: 10 }}>
<button onClick={startBlank}>Blank Adventure</button>
<button onClick={async () => {
try {
const bundle = await pickJSONFile()
const { scenario, unmapped_keys } = await api.importScenario(bundle)
if (unmapped_keys.length) {
toast(`Imported. Ignored unknown fields: ${unmapped_keys.join(', ')}`)
}
navigate(`/scenarios/${scenario.id}`)
} catch (err) { toast(err.message, 'error') }
}}>Import</button>
<button className="primary" onClick={createScenario}>+ New Scenario</button>
</div>
</div>
<div className="filter-bar">
<input
type="text"
className="search-input"
placeholder="Search scenarios…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{allTags.length > 0 && (
<div className="tag-row">
{allTags.map((tag) => (
<button
key={tag}
className={`tag ${tagFilter === tag ? 'active' : ''}`}
onClick={() => setTagFilter(tagFilter === tag ? null : tag)}
>
{tag}
</button>
))}
</div>
)}
</div>
{visible === null ? (
<CardSkeleton count={6} />
) : visible.length === 0 ? (
<div className="empty">
{scenarios.length === 0
? 'No scenarios yet. Create one to define a reusable story template.'
: 'No scenarios match your search.'}
</div>
) : (
<div className="card-grid">
{visible.map((sc, i) => (
<article
key={sc.id}
className="card tome enter"
style={{ animationDelay: `${Math.min(i, 10) * 50}ms` }}
onClick={() => navigate(`/scenarios/${sc.id}`)}
>
<div className="card-head">
<ScenarioArt image={sc.image_url} icon={sc.icon} title={sc.title} />
<div className="card-headings">
<h3>{sc.title}</h3>
</div>
</div>
<p className="snippet">{sc.description || 'No description yet.'}</p>
<footer className="card-foot">
<div className="tag-cluster">
{sc.is_public && (
<span className="tag small" title="Shared demo scenario (read-only)">demo ✦</span>
)}
{splitTags(sc.tags, { isPublic: sc.is_public }).slice(0, 3).map((tag) => (
<span key={tag} className="tag small">{tag}</span>
))}
</div>
<button className="primary compact" onClick={(e) => startAdventure(e, sc.id)}>
Play
</button>
</footer>
</article>
))}
</div>
)}
{pending && (
<BeginAdventureModal
title={pending.scenario ? pending.scenario.title : 'Blank Adventure'}
names={pending.names}
onCancel={() => setPending(null)}
onSubmit={(answers) => {
setPending(null)
begin(pending.scenario ? pending.scenario.id : null, answers)
}}
/>
)}
</div>
)
}
+388 -138
View File
@@ -1,205 +1,455 @@
/* Settings: where the storyteller gets its words from, and nothing else.
*
* `BROWSER-UX-SPEC.md` §43-45, §73, and §8/§24 of the M8 brief.
*
* ## The model is chosen from what is installed
*
* The inherited screen had `Model` as a free-text box. Typing a model name that
* is not pulled produces a correct-looking configuration that fails on every
* turn, and the connection test already knew the answer — it lists the
* endpoint's models and even warned when the configured one was missing. So the
* field is a picker over that listing, with the free-text box kept for the case
* the listing is empty or the reader wants a model that is not pulled yet.
*
* Nothing is chosen automatically. An endpoint's first model may well be an
* embedding model, which cannot narrate at all.
*
* ## No cloud provider anywhere
*
* §45. There is no provider selector, no sign-in and no API key field — the key
* field was removed in M2 with the deployment it served, and ADR 011 already
* refuses an endpoint that is not on this machine or this network. This screen
* states that boundary rather than offering to cross it.
*
* ## Diagnostics are kept, and folded away
*
* The raw request/response log is genuinely useful when a local model is
* misbehaving, and it is exactly the "developer console" §26 wants out of the
* normal path. It stays, behind a disclosure, at the bottom, clearly marked.
*/
import { useEffect, useState } from 'react'
import { api } from '../api'
import { useToast } from '../components'
import { useModelStatus } from '../modelStatus'
import { ModelSetupNotice } from '../ModelSetupNotice'
function DebugLog() {
const [entries, setEntries] = useState(null)
const [openId, setOpenId] = useState(null)
const load = () => api.getDebugRequests().then(setEntries).catch(() => setEntries([]))
useEffect(() => { load() }, [])
if (!entries) return null
return (
<div className="debug-log">
<div className="page-header" style={{ marginTop: 28 }}>
<h3 style={{ margin: 0 }}>Recent AI requests</h3>
<button onClick={load}>Refresh</button>
<details
className="advanced-block"
onToggle={(e) => { if (e.currentTarget.open && entries === null) load() }}
>
<summary>Recent requests to the model</summary>
<p className="field-hint">
The exact request and response for each recent turn. Useful when a local
model is answering strangely.
</p>
<div className="panel-actions">
<button type="button" onClick={load}>Refresh</button>
</div>
{entries.length === 0 && (
<div className="empty" style={{ padding: '16px 0' }}>
No requests yet this session. Play a turn and refresh.
</div>
{entries === null && <p className="dim">Open to load.</p>}
{entries?.length === 0 && (
<p className="dim">No requests yet this session. Play a turn and refresh.</p>
)}
{entries.map((e) => (
{entries?.map((e) => (
<div key={e.id} className="debug-entry">
<div className="debug-entry-head" onClick={() => setOpenId(openId === e.id ? null : e.id)}>
<button
type="button"
className="debug-entry-head"
aria-expanded={openId === e.id}
onClick={() => setOpenId(openId === e.id ? null : e.id)}
>
<span className={`debug-status ${e.status}`}>{e.status}</span>
<span>{e.model}</span>
<span className="dim">{new Date(e.time).toLocaleTimeString()}</span>
<span className="dim" style={{ marginLeft: 'auto' }}>{openId === e.id ? '▾' : '▸'}</span>
</div>
</button>
{openId === e.id && (
<div className="debug-entry-body">
<div className="dim">{e.url}</div>
{e.error && <div className="test-error">{e.error}</div>}
<div className="ctx-header" style={{ padding: '6px 0 2px' }}><span>Request</span></div>
<p className="dim">{e.url}</p>
{e.error && <p className="notice-detail error">{e.error}</p>}
<h4>Request</h4>
<pre>{JSON.stringify(e.request, null, 2)}</pre>
<div className="ctx-header" style={{ padding: '6px 0 2px' }}><span>Response</span></div>
<h4>Response</h4>
<pre>{e.response || '(empty)'}</pre>
</div>
)}
</div>
))}
</div>
</details>
)
}
/* M9. A verified copy of the whole database, on this machine.
*
* Deliberately small, and deliberately not a second export. The campaign export
* on the Campaigns screen is the tool for moving one campaign to another
* installation; this is the tool for keeping a copy of everything before doing
* something risky. Conflating them would leave a reader guessing which one
* answers "how do I not lose my campaigns".
*
* There is no restore button and no download link, and both absences are
* decisions rather than gaps:
*
* * **Restore** means replacing the file the running application has open,
* which is how somebody loses both copies at once. `DEVELOPMENT.md` carries
* the procedure — stop the app, move the file, start it — and it is a
* procedure precisely because each step needs the app to be stopped.
* * **Download** would put a copy of every campaign on the machine into the
* browser's download directory and its cache. For an application whose
* premise is that the story does not leave the machine, a path the reader
* can copy is the better default.
*/
function DatabaseBackup() {
const toast = useToast()
const [state, setState] = useState(null)
const [busy, setBusy] = useState(false)
const load = () => {
api.listBackups()
.then(setState)
.catch((err) => toast(err.message, 'error'))
}
const take = async () => {
setBusy(true)
try {
const result = await api.createBackup()
toast(`Backup written: ${result.filename} (${formatBytes(result.bytes)}).`)
load()
} catch (err) {
toast(err.message, 'error')
} finally {
setBusy(false)
}
}
return (
<details
className="advanced-block"
data-testid="database-backup"
onToggle={(e) => { if (e.currentTarget.open && state === null) load() }}
>
<summary>Back up everything on this machine</summary>
<p className="field-hint">
Writes a verified copy of the whole database — every campaign, every
imported file, every setting — beside the database itself. The copy is
checked before it is kept, and an existing backup is never overwritten.
To export a single campaign so it can be opened somewhere else, use
Export on the campaign instead.
</p>
<div className="panel-actions">
<button type="button" className="primary" onClick={take} disabled={busy}>
{busy ? 'Backing up…' : 'Back up now'}
</button>
</div>
{state && (
<>
<p className="field-hint">
Backups are written to <code>{state.directory}</code>. To restore
one, stop the application, put the file in place of the database, and
start it again.
</p>
{state.backups.length === 0 ? (
<p className="dim">No backups yet.</p>
) : (
<ul className="backup-list">
{state.backups.map((b) => (
<li key={b.filename}>
<code>{b.filename}</code>
<span className="dim">
{' '}{formatBytes(b.bytes)} · {new Date(b.taken_at).toLocaleString()}
</span>
</li>
))}
</ul>
)}
</>
)}
</details>
)
}
function formatBytes(bytes) {
if (!Number.isFinite(bytes)) return ''
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} kB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
export default function Settings() {
const [settings, setSettings] = useState(null)
const [testResult, setTestResult] = useState(null)
const [saved, setSaved] = useState('')
const [customModel, setCustomModel] = useState(false)
const toast = useToast()
const { models, refresh: refreshStatus, status } = useModelStatus()
useEffect(() => {
api.getSettings().then(setSettings)
}, [])
if (!settings) return null
const setField = (field, value) => setSettings({ ...settings, [field]: value })
const buildPayload = () => ({ ...settings })
const afterSave = async () => {
const fresh = await api.getSettings()
setSettings(fresh)
return fresh
}
const save = async () => {
try {
await api.updateSettings(buildPayload())
await afterSave()
setSaved('Settings saved')
await api.updateSettings(settings)
setSettings(await api.getSettings())
await refreshStatus()
setSaved('Saved')
setTimeout(() => setSaved(''), 4000)
} catch (err) {
setSaved(`Save failed: ${err.message}`)
toast(err.message, 'error')
}
setTimeout(() => setSaved(''), 4000)
}
const test = async () => {
setTestResult({ pending: true })
try {
await api.updateSettings(buildPayload())
await afterSave()
setTestResult(await api.testConnection())
await api.updateSettings(settings)
setSettings(await api.getSettings())
const result = await api.testConnection()
setTestResult(result)
await refreshStatus()
} catch (err) {
setTestResult({ ok: false, detail: err.message })
}
}
if (!settings) return null
// Show the picker when the endpoint told us what it has, unless the reader
// has deliberately asked to type a name instead.
const showPicker = models.length > 0 && !customModel
return (
<div className="page" style={{ maxWidth: 640 }}>
<div className="page settings">
<div className="page-header">
<h1>Settings</h1>
<span style={{ color: 'var(--text-dim)', fontSize: '0.85rem' }}>{saved}</span>
<span className="dim" role="status">{saved}</span>
</div>
<label className="field">
<span className="label">Ollama endpoint</span>
<input type="text" value={settings.endpoint_url}
placeholder="http://127.0.0.1:11434/v1"
onChange={(e) => setField('endpoint_url', e.target.value)} />
<span className="label" style={{ marginTop: 4 }}>
Ollama on this machine, or on a machine on your own network. A public
Internet address is refused — your story never leaves your own
infrastructure. An https:// endpoint whose certificate comes from your
own CA works once that CA is installed on this machine.
</span>
</label>
<label className="field">
<span className="label">Model</span>
<input type="text" value={settings.model} placeholder="e.g. qwen2.5:3b-instruct"
onChange={(e) => setField('model', e.target.value)} />
</label>
<div style={{ display: 'flex', gap: 14 }}>
<label className="field" style={{ flex: 1 }}>
<span className="label">Temperature</span>
<input type="number" step="0.1" min="0" max="2" value={settings.temperature}
onChange={(e) => setField('temperature', Number(e.target.value))} />
</label>
<label className="field" style={{ flex: 1 }}>
<span className="label">Max output tokens</span>
<input type="number" value={settings.max_output_tokens}
onChange={(e) => setField('max_output_tokens', Number(e.target.value))} />
</label>
<label className="field" style={{ flex: 1 }}>
<span className="label">Context budget (tokens)</span>
<input type="number" value={settings.context_token_budget}
onChange={(e) => setField('context_token_budget', Number(e.target.value))} />
</label>
</div>
<label className="field">
<span className="label">Model timeout (seconds)</span>
<input type="number" min="30" max="3600" value={settings.model_timeout_seconds}
onChange={(e) => setField('model_timeout_seconds', Number(e.target.value))} />
<span className="label" style={{ marginTop: 4 }}>
How long to wait for a turn. Loading a model for the first time can
take minutes on a machine without a GPU, while later turns take
seconds. Raise this if turns time out only after Ollama has been idle.
</span>
</label>
<label className="field">
<span className="label">API mode</span>
<select value={settings.api_mode} onChange={(e) => setField('api_mode', e.target.value)}>
<option value="chat">Chat (/v1/chat/completions)</option>
<option value="completion">Completion (/v1/completions)</option>
</select>
</label>
<div className="page-header" style={{ marginTop: 20 }}>
<h3 style={{ margin: 0 }}>Memory system</h3>
</div>
<div style={{ display: 'flex', gap: 14 }}>
<label className="field" style={{ flex: 1 }}>
<span className="label">Summarization model</span>
<input type="text" value={settings.summary_model}
placeholder="Empty = use main model"
onChange={(e) => setField('summary_model', e.target.value)} />
</label>
<label className="field" style={{ flex: 1 }}>
<span className="label">Embedding model</span>
<input type="text" value={settings.embedding_model}
placeholder="e.g. nomic-embed-text (empty = memory bank off)"
onChange={(e) => setField('embedding_model', e.target.value)} />
</label>
</div>
<div style={{ display: 'flex', gap: 14 }}>
<label className="field" style={{ flex: 1 }}>
<span className="label">Memory bank capacity</span>
<input type="number" min="1" value={settings.memory_bank_capacity}
onChange={(e) => setField('memory_bank_capacity', Number(e.target.value))} />
</label>
<label className="field" style={{ flex: 1 }}>
<span className="label">Memories per turn (top-K)</span>
<input type="number" min="1" value={settings.memory_top_k}
onChange={(e) => setField('memory_top_k', Number(e.target.value))} />
</label>
{/* §73 / §24. Stated plainly, once, at the top. */}
<div className="local-only" data-testid="local-only">
<strong>Runtime: local only</strong>
<p>
Your campaigns, your imported files and every prompt stay on this
machine. The one thing that leaves it is the request to the Ollama
endpoint below — which must be on this machine or your own network;
an address on the public internet is refused.
</p>
</div>
<label className="field">
<span className="label">Narrator system prompt</span>
<textarea rows={4} value={settings.narrator_prompt}
onChange={(e) => setField('narrator_prompt', e.target.value)} />
</label>
{status !== 'ready' && <ModelSetupNotice compact />}
<div style={{ display: 'flex', gap: 10, marginTop: 8 }}>
<button className="primary" onClick={save}>Save</button>
<button onClick={test}>Test connection</button>
</div>
<section className="settings-section">
<h2>The narrator</h2>
{testResult && (
<div style={{ marginTop: 16, color: testResult.ok ? 'var(--accent)' : 'var(--danger)' }}>
{testResult.pending ? 'Testing…'
: testResult.ok
? (testResult.warning
|| `Connected. Models: ${testResult.models?.slice(0, 8).join(', ') || '(none listed)'}`)
: testResult.detail}
<div className="field">
<label htmlFor="endpoint">Ollama endpoint</label>
<input
id="endpoint"
type="text"
value={settings.endpoint_url}
placeholder="http://127.0.0.1:11434/v1"
onChange={(e) => setField('endpoint_url', e.target.value)}
/>
<p className="field-hint">
Ollama on this machine, or on a machine on your own network. An{' '}
<code>https://</code> endpoint whose certificate comes from your own
CA works once that CA is installed on this machine.
</p>
</div>
)}
<DebugLog />
<div className="field">
<label htmlFor="model">Narrator model</label>
{showPicker ? (
<select
id="model"
value={settings.model || ''}
onChange={(e) => setField('model', e.target.value)}
>
<option value="">— choose a model —</option>
{models.map((m) => <option key={m} value={m}>{m}</option>)}
</select>
) : (
<input
id="model"
type="text"
value={settings.model || ''}
placeholder="qwen2.5:3b-instruct"
onChange={(e) => setField('model', e.target.value)}
/>
)}
<p className="field-hint">
{models.length > 0 ? (
<button
type="button"
className="linklike"
onClick={() => setCustomModel((c) => !c)}
>
{customModel ? 'Choose from installed models' : 'Type a model name instead'}
</button>
) : (
'Test the connection to list the models installed on that endpoint.'
)}
</p>
</div>
<div className="field-row">
<div className="field">
<label htmlFor="temperature">Temperature</label>
<input
id="temperature" type="number" step="0.1" min="0" max="2"
value={settings.temperature}
onChange={(e) => setField('temperature', Number(e.target.value))}
/>
<p className="field-hint">Higher is more surprising, lower more consistent.</p>
</div>
<div className="field">
<label htmlFor="max-output">Longest reply</label>
<input
id="max-output" type="number"
value={settings.max_output_tokens}
onChange={(e) => setField('max_output_tokens', Number(e.target.value))}
/>
<p className="field-hint">In tokens. Roughly ¾ of a word each.</p>
</div>
<div className="field">
<label htmlFor="ctx-budget">How much story to send</label>
<input
id="ctx-budget" type="number"
value={settings.context_token_budget}
onChange={(e) => setField('context_token_budget', Number(e.target.value))}
/>
<p className="field-hint">In tokens. Must fit your model’s window.</p>
</div>
</div>
<div className="field">
<label htmlFor="timeout">How long to wait for a turn</label>
<input
id="timeout" type="number" min="30" max="3600"
value={settings.model_timeout_seconds}
onChange={(e) => setField('model_timeout_seconds', Number(e.target.value))}
/>
<p className="field-hint">
In seconds. Loading a model for the first time can take minutes on a
machine without a GPU, while later turns take seconds.
</p>
</div>
<div className="panel-actions">
<button type="button" className="primary" onClick={save}>Save</button>
<button type="button" onClick={test}>Test connection</button>
</div>
{testResult && (
<p
className={`test-result ${testResult.ok ? 'ok' : 'bad'}`}
role="status"
data-testid="test-result"
>
{testResult.pending ? 'Testing…'
: testResult.ok
? (testResult.warning
|| `Connected. ${testResult.models?.length || 0} model(s) installed.`)
: testResult.detail}
</p>
)}
</section>
<section className="settings-section">
<h2>Memory and search</h2>
<p className="field-hint">
How the storyteller remembers a long campaign, and how it searches what
you have imported.
</p>
<div className="field-row">
<div className="field">
<label htmlFor="summary-model">Model for summarising</label>
<input
id="summary-model" type="text" value={settings.summary_model}
placeholder="Empty — use the narrator model"
onChange={(e) => setField('summary_model', e.target.value)}
/>
</div>
<div className="field">
<label htmlFor="embedding-model">Model for meaning-based search</label>
<input
id="embedding-model" type="text" value={settings.embedding_model}
placeholder="nomic-embed-text"
onChange={(e) => setField('embedding_model', e.target.value)}
/>
<p className="field-hint">
Empty means keyword search only, which is a supported setup.
</p>
</div>
</div>
<div className="field-row">
<div className="field">
<label htmlFor="bank-capacity">Memories kept</label>
<input
id="bank-capacity" type="number" min="1" value={settings.memory_bank_capacity}
onChange={(e) => setField('memory_bank_capacity', Number(e.target.value))}
/>
</div>
<div className="field">
<label htmlFor="bank-topk">Memories recalled per turn</label>
<input
id="bank-topk" type="number" min="1" value={settings.memory_top_k}
onChange={(e) => setField('memory_top_k', Number(e.target.value))}
/>
</div>
</div>
<div className="panel-actions">
<button type="button" className="primary" onClick={save}>Save</button>
</div>
</section>
<section className="settings-section">
<h2>Advanced</h2>
<details className="advanced-block">
<summary>Default narrator instructions</summary>
<p className="field-hint">
Used by every campaign that does not set its own. A campaign’s own
instructions are in its Settings panel.
</p>
<div className="field">
<label className="sr-only" htmlFor="narrator-prompt">
Default narrator instructions
</label>
<textarea
id="narrator-prompt" rows={5} value={settings.narrator_prompt}
onChange={(e) => setField('narrator_prompt', e.target.value)}
/>
</div>
<div className="field">
<label htmlFor="api-mode">How to talk to the model</label>
<select
id="api-mode" value={settings.api_mode}
onChange={(e) => setField('api_mode', e.target.value)}
>
<option value="chat">Chat</option>
<option value="completion">Completion</option>
</select>
</div>
<div className="panel-actions">
<button type="button" className="primary" onClick={save}>Save</button>
</div>
</details>
<DatabaseBackup />
<DebugLog />
</section>
</div>
)
}
+116
View File
@@ -0,0 +1,116 @@
/* M9: the database backup control, and what it must not become.
*
* The control itself is three lines of state, so the interesting assertions are
* about the boundaries around it rather than about the button:
*
* * it is **not** an export. The campaign export moves one campaign to
* another installation; this copies everything on this machine. A reader
* who cannot tell them apart has no way to answer "how do I not lose my
* campaigns", so the panel says which is which.
* * it offers **no restore and no download**, and both are decisions. Restore
* means replacing the file the running application has open; download means
* putting every campaign on the machine into the browser's cache.
* * a failure **says so**. A backup that silently did not happen is worse
* than no backup, because the reader believes they have one.
*/
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from '../api'
import Settings from './Settings'
import { mockModelStatus, renderWith } from '../test/helpers'
const LISTING = {
directory: '/home/reader/.adventure/backups',
backups: [
{ filename: 'adventure-storyteller-20260907-043000.db', bytes: 2_400_000,
taken_at: '2026-09-07T04:30:00' },
{ filename: 'adventure-storyteller-20260901-101500.db', bytes: 2_100_000,
taken_at: '2026-09-01T10:15:00' },
],
}
beforeEach(() => { vi.restoreAllMocks() })
async function openTheBackupPanel() {
mockModelStatus(api)
vi.spyOn(api, 'listBackups').mockResolvedValue(LISTING)
await renderWith(<Settings />)
const panel = screen.getByTestId('database-backup')
await userEvent.click(screen.getByText(/Back up everything on this machine/i))
return panel
}
describe('the backup control', () => {
it('lists the backups already on disk, and where they are', async () => {
await openTheBackupPanel()
await waitFor(() => {
expect(screen.getByText('adventure-storyteller-20260907-043000.db')).toBeInTheDocument()
})
expect(screen.getByText('adventure-storyteller-20260901-101500.db')).toBeInTheDocument()
expect(screen.getByText('/home/reader/.adventure/backups')).toBeInTheDocument()
})
it('takes a backup and reports what was written', async () => {
const panel = await openTheBackupPanel()
const create = vi.spyOn(api, 'createBackup').mockResolvedValue({
directory: LISTING.directory,
filename: 'adventure-storyteller-20260907-050000.db',
bytes: 2_500_000, pages: 610, seconds: 0.02, integrity: 'ok',
})
await userEvent.click(screen.getByRole('button', { name: /Back up now/i }))
await waitFor(() => expect(create).toHaveBeenCalled())
expect(await screen.findByText(/Backup written/)).toBeInTheDocument()
expect(screen.getByText(/adventure-storyteller-20260907-050000\.db/)).toBeInTheDocument()
expect(panel).toBeInTheDocument()
})
it('reloads the list afterwards, so the new file is visible', async () => {
await openTheBackupPanel()
vi.spyOn(api, 'createBackup').mockResolvedValue({
directory: LISTING.directory, filename: 'new.db', bytes: 1, integrity: 'ok',
})
await userEvent.click(screen.getByRole('button', { name: /Back up now/i }))
await waitFor(() => expect(api.listBackups).toHaveBeenCalledTimes(2))
})
it('reports a failure rather than looking as though it worked', async () => {
await openTheBackupPanel()
vi.spyOn(api, 'createBackup').mockRejectedValue(
new Error('The backup was written but did not verify: page 4 missing.'),
)
await userEvent.click(screen.getByRole('button', { name: /Back up now/i }))
expect(await screen.findByText(/did not verify/)).toBeInTheDocument()
expect(screen.queryByText(/Backup written/)).not.toBeInTheDocument()
})
it('says which tool this is, and which one moves one campaign', async () => {
await openTheBackupPanel()
expect(screen.getByText(/every campaign, every/i)).toBeInTheDocument()
expect(
screen.getByText(/To export a single campaign so it can be opened somewhere else/i),
).toBeInTheDocument()
})
it('offers no restore button and no download link', async () => {
const panel = await openTheBackupPanel()
await waitFor(() => {
expect(screen.getByText(LISTING.directory)).toBeInTheDocument()
})
const labels = [...panel.querySelectorAll('button')].map((b) => b.textContent)
expect(labels.some((label) => /restore/i.test(label))).toBe(false)
expect(labels.some((label) => /download/i.test(label))).toBe(false)
expect(panel.querySelectorAll('a[download]')).toHaveLength(0)
expect(panel.querySelectorAll('a[href]')).toHaveLength(0)
// And the procedure is stated instead, so the absence is an answer.
expect(screen.getByText(/stop the application/i)).toBeInTheDocument()
})
it('does not fetch anything until the panel is opened', async () => {
mockModelStatus(api)
const list = vi.spyOn(api, 'listBackups').mockResolvedValue(LISTING)
await renderWith(<Settings />)
expect(list).not.toHaveBeenCalled()
})
})
-287
View File
@@ -1,287 +0,0 @@
/* ---------- Visit analytics (owner dashboard) ---------- */
/* Wider than the reading pages: this one is a grid of small figures, not
prose, and 960px puts two charts and eight tiles into a column. */
.an-page { max-width: 1180px; }
.an-ranges { display: flex; flex-wrap: wrap; gap: 6px; }
/* Four across, not auto-fit: there are eight tiles, and letting them flow
leaves a single orphan on the second row at most widths. */
.an-tiles {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 18px;
}
@media (max-width: 900px) {
.an-tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
.an-tile {
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 14px 16px;
}
.an-tile-value {
font-family: var(--font-display);
font-size: 1.7rem;
line-height: 1.1;
color: var(--accent-bright);
}
.an-tile-label { font-size: 0.82rem; color: var(--text); margin-top: 4px; }
.an-tile-hint { font-size: 0.72rem; color: var(--text-dim); margin-top: 2px; }
.an-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
gap: 16px;
margin-bottom: 16px;
}
.an-card {
position: relative;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px 18px 14px;
margin-bottom: 16px;
}
.an-grid .an-card { margin-bottom: 0; }
.an-card-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.an-card-head h2 {
margin: 0;
font-family: var(--font-display);
font-size: 0.95rem;
letter-spacing: 0.06em;
color: var(--accent);
}
.an-note { font-size: 0.72rem; color: var(--text-dim); }
.an-empty { color: var(--text-dim); font-style: italic; padding: 18px 0; text-align: center; }
.an-legend { display: flex; gap: 12px; font-size: 0.72rem; color: var(--text-dim); }
.an-legend-item { display: inline-flex; align-items: center; gap: 5px; }
.an-swatch { width: 9px; height: 9px; border-radius: 2px; display: inline-block; flex: none; }
/* ----- Day columns ----- */
.an-plot { position: relative; height: 170px; margin-top: 12px; }
.an-gridline {
position: absolute;
left: 0;
right: 0;
border-top: 1px dashed var(--border);
pointer-events: none;
}
.an-gridline span {
position: absolute;
right: 0;
top: -0.62em;
font-size: 0.68rem;
color: var(--text-dim);
background: var(--bg-panel);
padding: 0 4px;
/* The dashed rule belongs behind the bars; its value does not. Columns are
positioned, so without this the tallest bar swallows the label. */
z-index: 3;
}
.an-columns { display: flex; align-items: flex-end; gap: 2px; height: 100%; }
.an-column {
position: relative;
flex: 1 1 0;
min-width: 0;
height: 100%;
display: flex;
align-items: flex-end;
border-radius: 4px 4px 0 0;
outline: none;
}
.an-column.hot { background: rgba(212, 169, 78, 0.07); }
.an-stack {
width: 100%;
/* Capped so a 7-day range draws bars and not slabs; the column around it
stays full width, so the hover target does not shrink with the mark. */
max-width: 44px;
margin: 0 auto;
height: 100%;
display: flex;
flex-direction: column;
justify-content: flex-end;
/* A 2px gap of surface between stacked segments: the boundary reads as a
boundary without a border, which would eat a thin bar entirely. */
gap: 2px;
}
.an-bar { width: 100%; transition: filter 0.15s; }
.an-stack > .an-bar:first-child { border-radius: 4px 4px 0 0; }
.an-column.hot .an-bar { filter: brightness(1.2); }
.an-tip {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
gap: 3px;
padding: 8px 10px;
background: var(--bg-panel);
border: 1px solid var(--border-bright);
border-radius: 8px;
box-shadow: 0 10px 26px rgba(0, 0, 0, 0.55);
font-size: 0.74rem;
white-space: nowrap;
pointer-events: none;
z-index: 4;
}
.an-tip strong { font-weight: 600; color: var(--accent-bright); }
.an-tip span { display: inline-flex; align-items: center; gap: 6px; color: var(--text-dim); }
.an-axis { display: flex; gap: 2px; margin-top: 7px; }
.an-axis span {
flex: 1 1 0;
min-width: 0;
font-size: 0.68rem;
color: var(--text-dim);
text-align: center;
white-space: nowrap;
}
.an-axis span:first-child { text-align: left; }
.an-axis span:last-child { text-align: right; }
.an-overlay-empty {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-dim);
font-style: italic;
font-size: 0.85rem;
pointer-events: none;
}
/* ----- Funnel ----- */
.an-funnel { display: flex; flex-direction: column; gap: 10px; }
.an-funnel-row {
display: grid;
grid-template-columns: 168px 1fr 96px;
align-items: center;
gap: 12px;
}
.an-funnel-label { font-size: 0.84rem; }
.an-funnel-track { height: 14px; background: var(--bg-input); border-radius: 7px; overflow: hidden; }
.an-funnel-bar {
height: 100%;
min-width: 2px;
background: var(--chart-1);
border-radius: 7px;
transition: width 0.4s ease;
}
.an-funnel-value {
text-align: right;
font-size: 0.86rem;
display: flex;
justify-content: flex-end;
align-items: baseline;
gap: 8px;
}
.an-funnel-share { font-size: 0.72rem; color: var(--text-dim); }
/* ----- Ranked lists ----- */
.an-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 3px; }
.an-list li {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 7px 10px;
border-radius: 6px;
font-size: 0.84rem;
}
/* The bar sits behind the row rather than beside it, so a long label keeps the
full width and the magnitude is still visible at a glance. */
.an-list-fill {
position: absolute;
top: 0;
bottom: 0;
left: 0;
background: rgba(181, 138, 48, 0.2);
border-radius: 6px;
}
.an-list-label {
position: relative;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.an-list-value { position: relative; color: var(--text-dim); flex: none; }
/* ----- Tabs, and the access log table ----- */
.an-tabs { display: flex; gap: 4px; margin-bottom: 16px; border-bottom: 1px solid var(--border); }
.an-tabs button {
background: none;
border: none;
border-bottom: 2px solid transparent;
border-radius: 0;
padding: 8px 14px;
color: var(--text-dim);
font-family: var(--font-display);
font-size: 0.82rem;
letter-spacing: 0.06em;
cursor: pointer;
}
.an-tabs button:hover { color: var(--text); }
.an-tabs button.active { color: var(--accent-bright); border-bottom-color: var(--accent); }
.an-log-head { flex-wrap: wrap; row-gap: 10px; }
.an-log-search { max-width: 260px; }
/* The table is the one thing here with a minimum width — six columns of real
data don't compress. It scrolls inside its own box so the page never does. */
.an-table-scroll { overflow-x: auto; }
.an-table { width: 100%; min-width: 620px; border-collapse: collapse; font-size: 0.82rem; }
.an-table th {
text-align: left;
font-weight: 500;
font-size: 0.72rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-dim);
padding: 0 10px 8px;
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
.an-table td { padding: 8px 10px; border-bottom: 1px solid var(--border); white-space: nowrap; }
.an-table tr:last-child td { border-bottom: none; }
.an-table tbody tr:hover { background: rgba(212, 169, 78, 0.05); }
/* A failed attempt is the row you are scanning for; it gets the only colour in
the table, on a border rather than the text, which stays readable. */
.an-table tr.failed td:first-child { box-shadow: inset 2px 0 var(--danger); }
.an-table tr.failed td:nth-child(3) { color: var(--danger); }
.an-cell-dim { color: var(--text-dim); }
.an-cell-mono { font-family: ui-monospace, 'Cascadia Code', Consolas, monospace; }
.an-tag {
margin-left: 8px;
padding: 1px 6px;
border: 1px solid var(--border-bright);
border-radius: 10px;
font-size: 0.66rem;
color: var(--text-dim);
}
.an-more { display: flex; justify-content: center; padding-top: 14px; }
.an-footnote {
color: var(--text-dim);
font-size: 0.74rem;
line-height: 1.6;
margin: 22px 0 0;
max-width: 70ch;
}
-145
View File
@@ -1,145 +0,0 @@
/* ---------- Auth (log in / sign up) ---------- */
.auth-modal { width: min(400px, calc(100vw - 40px)); text-align: center; padding-top: 22px; }
.auth-crest {
font-size: 1.1rem;
color: var(--accent-dim);
letter-spacing: 0.3em;
margin-bottom: 6px;
}
.auth-modal h2 { font-size: 1.35rem; }
.auth-modal .auth-hint { margin-bottom: 16px; line-height: 1.45; }
/* Fields go back to left-aligned — only the header is centred. */
.auth-modal label.field { text-align: left; }
.auth-tabs {
display: flex;
gap: 4px;
padding: 4px;
margin-bottom: 18px;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 10px;
}
.auth-tab {
flex: 1;
padding: 7px 0;
border: 1px solid transparent;
border-radius: 7px;
background: transparent;
color: var(--text-dim);
font-family: var(--font-display);
font-size: 0.78rem;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.auth-tab:hover { color: var(--text); background: rgba(212, 169, 78, 0.06); }
.auth-tab.active {
background: var(--bg-panel);
border-color: var(--accent-dim);
color: var(--accent-bright);
}
.auth-password { display: flex; gap: 8px; }
.auth-password input { flex: 1; min-width: 0; }
.auth-reveal {
flex: none;
padding: 0 12px;
font-size: 0.78rem;
color: var(--text-dim);
}
.auth-reveal:hover { color: var(--accent-bright); }
.auth-help {
display: block;
margin-top: 6px;
font-size: 0.76rem;
color: var(--text-dim);
}
.auth-error {
margin-top: 4px;
padding: 9px 12px;
border: 1px solid rgba(208, 101, 101, 0.45);
border-radius: 8px;
background: rgba(208, 101, 101, 0.09);
color: var(--danger);
font-size: 0.85rem;
text-align: left;
white-space: pre-wrap;
}
/* "Update from scenario" — a wider modal, since it lists a diff. */
.refresh-modal { width: min(620px, calc(100vw - 40px)); }
.refresh-changes {
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px 14px;
font-size: 0.86rem;
}
.refresh-change + .refresh-change {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--border);
}
.refresh-old, .refresh-new { margin-top: 3px; font-family: var(--font-mono, monospace); font-size: 0.8rem; }
.refresh-old { text-decoration: line-through; }
.refresh-new { color: var(--accent-bright); }
/* The button sits above the plot fields it would overwrite. */
.plot-source {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border);
font-size: 0.82rem;
}
.plot-source .linklike { flex-shrink: 0; }
/* ---------- Debug log (Settings) ---------- */
.debug-entry {
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 8px;
overflow: hidden;
}
.debug-entry-head {
display: flex;
align-items: baseline;
gap: 12px;
padding: 9px 12px;
cursor: pointer;
font-size: 0.86rem;
background: var(--bg-input);
}
.debug-entry-head .dim, .debug-entry-body .dim { color: var(--text-dim); font-size: 0.8rem; }
.debug-status {
font-size: 0.68rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 1px 8px;
border-radius: 999px;
border: 1px solid var(--border-bright);
color: var(--text-dim);
}
.debug-status.ok { color: #7dc9a2; border-color: #7dc9a244; }
.debug-status.error { color: var(--danger); border-color: var(--danger); }
.debug-status.pending { color: var(--accent); border-color: var(--accent-dim); }
.debug-entry-body { padding: 10px 12px; font-size: 0.85rem; }
.debug-entry-body pre {
margin: 2px 0 8px;
padding: 8px 10px;
background: var(--bg-input);
border-radius: 6px;
white-space: pre-wrap;
word-break: break-word;
font-family: Consolas, monospace;
font-size: 0.78rem;
max-height: 300px;
overflow-y: auto;
}
-25
View File
@@ -1,25 +0,0 @@
/* ---------- Play screen error/success banner ----------
Persistent (no auto-dismiss) and can carry a Retry action, so it stays
separate from the transient global toasts further down this file. */
.play-toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
background: var(--bg-panel-glass);
backdrop-filter: blur(10px);
border: 1px solid var(--danger);
color: var(--text);
padding: 11px 20px;
border-radius: 10px;
z-index: 100;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5);
animation: toast-in 0.25s ease both;
}
.play-toast.ok { border-color: var(--accent); box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5), 0 0 20px var(--accent-glow); }
@keyframes toast-in {
from { opacity: 0; transform: translate(-50%, 12px); }
to { opacity: 1; transform: translate(-50%, 0); }
}
-97
View File
@@ -1,97 +0,0 @@
/* ---------- AI Chat (power users) ----------
A plain chat scratchpad, styled as a transcript rather than as the story
page: UI font, role labels, and a sticky composer at the bottom. */
.chat-page {
max-width: 820px;
display: flex;
flex-direction: column;
margin-bottom: var(--kb-inset, 0px); /* see .play-page */
}
.chat-meta {
font-size: 0.78rem;
margin: -10px 0 16px;
word-break: break-word;
}
.chat-options {
border: 1px solid var(--border);
border-radius: 10px;
padding: 14px 16px 4px;
margin-bottom: 18px;
background: var(--bg-panel);
}
.chat-transcript { flex: 1; display: flex; flex-direction: column; gap: 16px; padding-bottom: 20px; }
.chat-msg {
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px 14px 12px;
background: var(--bg-panel);
}
.chat-msg.user { border-left: 3px solid var(--player); background: var(--bg-input); }
.chat-msg.assistant { border-left: 3px solid var(--accent-dim); }
.chat-msg-head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 6px;
font-size: 0.72rem;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.chat-msg .chat-role { color: var(--accent-dim); font-weight: 600; }
.chat-msg.user .chat-role { color: var(--player); }
.chat-model-tag { text-transform: none; letter-spacing: 0; font-size: 0.74rem; }
.chat-msg-actions {
margin-left: auto;
display: flex;
gap: 12px;
opacity: 0;
transition: opacity 0.15s;
}
.chat-msg:hover .chat-msg-actions, .chat-msg:focus-within .chat-msg-actions { opacity: 1; }
.chat-msg-body {
white-space: pre-wrap;
overflow-wrap: anywhere;
line-height: 1.6;
font-size: 0.95rem;
}
.chat-msg .cursor {
color: var(--accent-bright);
text-shadow: 0 0 8px var(--accent-glow);
animation: blink 1s steps(1) infinite;
}
/* Same collapsed thinking block as the story page, re-scoped for the transcript. */
.chat-msg .reasoning { margin: 0 0 8px; font-size: 0.82rem; line-height: 1.5; color: var(--text-dim); }
.chat-msg .reasoning summary { cursor: pointer; user-select: none; opacity: 0.8; }
.chat-msg .reasoning summary:hover { opacity: 1; }
.chat-msg .reasoning .reasoning-text {
margin-top: 6px;
padding: 8px 12px;
border-left: 2px solid var(--border);
white-space: pre-wrap;
opacity: 0.85;
max-height: 260px;
overflow-y: auto;
}
.chat-composer {
position: sticky;
bottom: var(--kb-inset, 0px); /* clears the on-screen keyboard — see .play-controls */
display: flex;
gap: 10px;
align-items: flex-end;
padding: 14px 0;
background: linear-gradient(to top, var(--bg) 55%, transparent);
}
.chat-composer textarea {
flex: 1;
min-height: 44px;
max-height: 220px;
resize: none;
overflow-y: auto;
}
.chat-composer button { flex: none; height: 40px; }
+168
View File
@@ -0,0 +1,168 @@
/* The context inspector, and the knowledge library's list.
*
* §55's rule shapes both: readable summaries first, raw text last and folded
* away. Everything here is UI-font and small — this is the layer under the
* story, and it should not compete with it.
*/
.context-panel { display: flex; flex-direction: column; gap: 14px; font-size: 0.82rem; }
.ctx-mode {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
color: var(--text-dim);
font-size: 0.78rem;
}
.ctx-mode button { padding: 3px 9px; font-size: 0.72rem; }
.ctx-tokens {
padding: 11px 13px;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 7px;
}
.ctx-tokens.over { border-color: var(--danger); }
.ctx-token-line {
display: flex;
justify-content: space-between;
gap: 10px;
padding: 2px 0;
font-variant-numeric: tabular-nums;
}
.ctx-warn { margin: 8px 0 0; color: var(--danger); font-size: 0.76rem; }
.token-bar {
display: flex;
height: 6px;
margin-top: 9px;
overflow: hidden;
border-radius: 3px;
background: var(--bg);
}
.token-bar-slice { height: 100%; }
.slice-0 { background: var(--accent); }
.slice-1 { background: var(--player); }
.slice-2 { background: #b39ddb; }
.slice-3 { background: var(--accent-dim); }
.slice-4 { background: #6f9e8c; }
.slice-5 { background: #c48a6a; }
.slice-6 { background: #7c86b8; }
.slice-7 { background: var(--border-bright); }
.ctx-block {
border: 1px solid var(--border);
border-radius: 7px;
background: var(--bg-panel);
}
.ctx-block > summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 9px 12px;
cursor: pointer;
list-style: none;
}
.ctx-block > summary::-webkit-details-marker { display: none; }
.ctx-block > summary::before {
content: '▸';
margin-right: 7px;
color: var(--text-dim);
font-size: 0.7rem;
}
.ctx-block[open] > summary::before { content: '▾'; }
.ctx-block-title { flex: 1; font-weight: 600; }
.ctx-block-count {
padding: 0 7px;
font-size: 0.7rem;
color: var(--text-dim);
font-variant-numeric: tabular-nums;
}
.ctx-block-body { padding: 0 12px 12px; }
.ctx-block-body > p:first-child { margin-top: 0; }
.ctx-rows { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
.ctx-row {
padding: 9px 11px;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 6px;
}
.ctx-row-head { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; margin-bottom: 4px; }
.ctx-row-meta {
display: flex;
gap: 10px;
flex-wrap: wrap;
font-size: 0.72rem;
color: var(--text-dim);
}
.ctx-row-text, .ctx-row-text-plain {
margin: 7px 0 0;
padding: 8px 10px;
max-height: 16em;
overflow: auto;
font-family: var(--font-story);
font-size: 0.92rem;
line-height: 1.55;
white-space: pre-wrap;
background: var(--bg);
border-radius: 5px;
}
.ctx-row-withheld {
margin: 7px 0 0;
padding: 8px 10px;
font-style: italic;
color: var(--text-dim);
background: var(--bg);
border: 1px dashed var(--border-bright);
border-radius: 5px;
}
.ctx-source-link { font-weight: 600; }
.ctx-foot { margin: 9px 0 0; font-size: 0.74rem; }
.ctx-note { margin: 9px 0 0; font-size: 0.76rem; color: var(--accent-dim); line-height: 1.55; }
/* §19. Deliberately not styled as a quiet checkbox: it reveals story secrets,
and it should look like it means something. */
.reveal-toggle {
display: flex;
align-items: flex-start;
gap: 8px;
margin-bottom: 11px;
padding: 8px 10px;
font-size: 0.76rem;
line-height: 1.5;
color: var(--text-dim);
background: var(--bg-input);
border: 1px dashed var(--accent-dim);
border-radius: 6px;
cursor: pointer;
}
.reveal-toggle input { margin-top: 2px; }
.ctx-settings { display: grid; grid-template-columns: auto 1fr; gap: 3px 14px; margin: 0; }
.ctx-settings dt { color: var(--text-dim); }
.ctx-settings dd { margin: 0; font-variant-numeric: tabular-nums; }
.ctx-raw-section { margin-top: 10px; }
.ctx-raw-head {
display: flex;
justify-content: space-between;
gap: 10px;
padding: 5px 0;
font-size: 0.72rem;
color: var(--accent-dim);
border-bottom: 1px solid var(--border);
}
.ctx-raw-section pre {
margin: 6px 0 0;
padding: 9px 11px;
max-height: 22em;
overflow: auto;
font-size: 0.74rem;
line-height: 1.5;
white-space: pre-wrap;
background: var(--bg-input);
border-radius: 5px;
}
+56
View File
@@ -0,0 +1,56 @@
/* The settings screen's request log. What remains of `auth.css`, whose
log-in, sign-up and "update from scenario" rules went with the surfaces
they styled (M8 §26). */
/* ---------- Debug log (Settings) ---------- */
.debug-entry {
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 8px;
overflow: hidden;
}
.debug-entry-head {
display: flex;
align-items: baseline;
gap: 12px;
padding: 9px 12px;
cursor: pointer;
font-size: 0.86rem;
background: var(--bg-input);
}
.debug-entry-head .dim, .debug-entry-body .dim { color: var(--text-dim); font-size: 0.8rem; }
.debug-status {
font-size: 0.68rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 1px 8px;
border-radius: 999px;
border: 1px solid var(--border-bright);
color: var(--text-dim);
}
.debug-status.ok { color: #7dc9a2; border-color: #7dc9a244; }
.debug-status.error { color: var(--danger); border-color: var(--danger); }
.debug-status.pending { color: var(--accent); border-color: var(--accent-dim); }
.debug-entry-body { padding: 10px 12px; font-size: 0.85rem; }
.debug-entry-body pre {
margin: 2px 0 8px;
padding: 8px 10px;
background: var(--bg-input);
border-radius: 6px;
white-space: pre-wrap;
word-break: break-word;
font-family: Consolas, monospace;
font-size: 0.78rem;
max-height: 300px;
overflow-y: auto;
}
+71
View File
@@ -0,0 +1,71 @@
/* Modal dialogs, and the class tags shared by the knowledge and context panels. */
.dialog-backdrop {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(4, 4, 8, 0.72);
backdrop-filter: blur(3px);
animation: dialog-fade 0.15s ease both;
}
@keyframes dialog-fade { from { opacity: 0; } }
.dialog {
width: min(460px, 100%);
max-height: 86vh;
overflow-y: auto;
padding: 22px 24px;
background: var(--bg-panel);
border: 1px solid var(--border-bright);
border-radius: 11px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
}
.dialog:focus { outline: none; }
.dialog-title { margin: 0 0 12px; font-size: 1.05rem; }
.dialog-body { font-size: 0.87rem; line-height: 1.65; color: var(--text-dim); }
.dialog-body p { margin: 0 0 10px; }
.dialog-body p:last-child { margin-bottom: 0; }
.dialog-field { display: flex; flex-direction: column; gap: 5px; margin-top: 16px; }
.dialog-field span { font-size: 0.76rem; color: var(--text-dim); }
.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; }
.external-href {
padding: 8px 10px;
overflow-wrap: anywhere;
background: var(--bg-input);
border-radius: 5px;
}
/* ---------- Classification tags ---------- *
* One definition, used by the knowledge library and the context inspector, so
* a class looks the same wherever a reader meets it (§48). */
.class-tag {
display: inline-block;
padding: 1px 8px;
font-family: var(--font-ui);
font-size: 0.66rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
border-radius: 10px;
border: 1px solid currentColor;
}
.class-canon { color: var(--accent-bright); }
.class-reference { color: var(--player); }
.class-inspiration { color: #b39ddb; }
.hidden-tag {
display: inline-block;
padding: 1px 8px;
font-family: var(--font-ui);
font-size: 0.66rem;
letter-spacing: 0.04em;
color: var(--bg);
background: var(--accent-dim);
border-radius: 10px;
}
-266
View File
@@ -1,266 +0,0 @@
/* ---------- Left status drawer (script state) ---------- */
.status-drawer {
flex-shrink: 0;
position: sticky;
top: 56px;
height: calc(100dvh - 56px);
display: flex;
align-items: flex-start;
border-right: 1px solid var(--border);
background: var(--bg-panel);
}
.status-drawer.open { width: 300px; }
.status-toggle {
flex-shrink: 0;
height: 100%;
width: 30px;
padding: 12px 0;
border: none;
border-radius: 0;
background: transparent;
color: var(--text-dim);
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.status-toggle:hover { color: var(--text); background: var(--bg-input); }
.status-toggle-label {
writing-mode: vertical-rl;
text-orientation: mixed;
letter-spacing: 0.12em;
font-size: 0.72rem;
text-transform: uppercase;
}
.status-body {
flex: 1;
min-width: 0;
height: 100%;
overflow-y: auto;
padding: 20px 16px;
animation: panel-in 0.2s ease both;
}
.status-vars { list-style: none; margin: 0; padding: 0; }
.status-vars li {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 0;
border-bottom: 1px solid var(--border);
}
.status-key {
font-family: var(--font-mono, monospace);
font-size: 0.82rem;
color: var(--accent-bright);
word-break: break-word;
}
.status-val {
font-family: var(--font-mono, monospace);
font-size: 0.82rem;
color: var(--text);
white-space: pre-wrap;
word-break: break-word;
}
/* Recursive script-state value tree */
.jv-string { color: var(--text); }
.jv-number { color: var(--player); }
.jv-bool { color: var(--accent-bright); }
.jv-null { color: var(--text-dim); font-style: italic; }
.jv-empty { color: var(--text-dim); }
.jv-tree { display: inline-block; vertical-align: top; max-width: 100%; }
.jv-toggle {
background: none; border: none; padding: 0; cursor: pointer;
font-family: inherit; font-size: 0.72rem; color: var(--text-dim);
}
.jv-toggle:hover { color: var(--accent-bright); }
.jv-children {
list-style: none; margin: 2px 0 2px 0; padding-left: 12px;
border-left: 1px solid var(--border);
}
.jv-children li { padding: 2px 0; word-break: break-word; }
.jv-key { color: var(--accent); margin-right: 6px; }
.jv-key::after { content: ':'; color: var(--text-dim); }
@keyframes panel-in {
from { opacity: 0; transform: translateX(16px); }
to { opacity: 1; transform: none; }
}
/* ---------- World State drawer (Phase 12 RPG) ---------- */
.ws-group { margin-bottom: 18px; }
.ws-group-title {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-dim);
margin: 0 0 8px;
}
.ws-stat { padding: 5px 0; }
.ws-stat-head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 8px;
font-size: 0.85rem;
}
.ws-stat-name { color: var(--text); text-transform: capitalize; }
.ws-stat-val {
color: var(--text-dim);
font-variant-numeric: tabular-nums;
font-size: 0.8rem;
text-align: right;
}
.ws-band { color: var(--accent-bright); }
.ws-bar {
margin-top: 4px;
height: 5px;
border-radius: 3px;
background: var(--bg-input);
overflow: hidden;
}
.ws-bar-fill {
height: 100%;
border-radius: 3px;
background: var(--accent);
transition: width 0.35s ease;
}
/* Cast — each NPC is a small plate inside the one "Cast" group, so a roster of
five doesn't read as five more unrelated sections in the rail. */
.ws-npc {
padding: 7px 9px;
margin-bottom: 7px;
border: 1px solid var(--border);
border-left: 2px solid var(--accent-dim);
border-radius: 7px;
background: rgba(255, 255, 255, 0.022);
}
.ws-npc:last-child { margin-bottom: 0; }
.ws-npc-head { display: flex; align-items: center; gap: 7px; margin-bottom: 3px; }
.ws-npc-avatar {
flex-shrink: 0;
width: 22px; height: 22px;
border-radius: 50%;
display: grid; place-items: center;
font-family: var(--font-display);
font-size: 0.6rem;
color: #14100a;
background: linear-gradient(150deg, var(--accent-bright), var(--accent-dim));
}
.ws-npc-name {
font-family: var(--font-display);
font-size: 0.85rem;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ws-none { font-size: 0.78rem; color: var(--text-dim); font-style: italic; }
.ws-milestones { list-style: none; margin: 0; padding: 0; }
.ws-milestones li {
display: flex;
gap: 7px;
align-items: flex-start;
padding: 4px 0;
font-size: 0.85rem;
color: var(--text);
}
.ws-milestones li.done { color: var(--text-dim); }
.ws-check { flex-shrink: 0; color: var(--accent-bright); }
.ws-milestones li.done .ws-check { color: var(--player); }
.ws-flags { list-style: none; margin: 0; padding: 0; }
.ws-flags li {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
font-size: 0.85rem;
color: var(--text);
}
.ws-flag-dot {
width: 9px; height: 9px; border-radius: 50%;
flex-shrink: 0;
background: var(--bg-input);
border: 1px solid var(--border);
}
.ws-flag-dot.on { background: var(--accent); border-color: var(--accent); }
.ws-flag-name { flex: 1; }
.ws-flag-val { color: var(--text-dim); font-size: 0.8rem; }
.ws-flag-edit { display: flex; align-items: center; gap: 8px; cursor: pointer; width: 100%; }
.ws-edit-actions { display: flex; gap: 6px; }
.ws-edit-input {
width: 100%;
margin-top: 3px;
padding: 4px 7px;
font-size: 0.82rem;
border-radius: 6px;
}
.schema-editor {
width: 100%;
font-family: var(--font-mono, monospace);
font-size: 0.82rem;
line-height: 1.5;
tab-size: 2;
white-space: pre;
overflow-x: auto;
}
.schema-error {
margin-top: 6px;
color: var(--danger, #e5484d);
font-size: 0.82rem;
}
.schema-json-actions {
display: flex;
align-items: center;
gap: 12px;
margin-top: 8px;
}
.schema-json-actions .schema-error { margin-top: 0; }
.schema-preview {
margin-top: 14px;
padding: 14px 16px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-panel);
}
.schema-preview-title {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--text-dim);
margin-bottom: 10px;
}
.schema-group { margin-bottom: 16px; }
.schema-group:last-child { margin-bottom: 0; }
.schema-group-title {
font-family: Georgia, serif;
font-size: 0.98rem;
color: var(--accent-bright);
margin-bottom: 6px;
}
.schema-npc-id {
font-family: var(--font-mono, monospace);
font-size: 0.72rem;
color: var(--text-dim);
margin-left: 6px;
}
.schema-stat { padding: 4px 0; border-bottom: 1px solid var(--border); }
.schema-stat:last-child { border-bottom: none; }
.schema-stat-head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
}
.schema-stat-name { color: var(--text); text-transform: capitalize; }
.schema-stat-meta {
color: var(--text-dim);
font-size: 0.8rem;
font-variant-numeric: tabular-nums;
text-align: right;
}
.schema-desc { color: var(--text-dim); font-size: 0.8rem; margin-top: 2px; }
.schema-bands { color: var(--text-dim); font-size: 0.76rem; margin-top: 2px; font-style: italic; }
+280
View File
@@ -0,0 +1,280 @@
/* The campaign library, the setup form, and the model status badge.
*
* Everything above a campaign. Deliberately plain: this screen is passed
* through on the way to a story, and the thing a returning reader is looking
* for is the title of the story they were in.
*/
.page {
width: min(980px, 100%);
margin: 0 auto;
padding: 30px 24px 80px;
}
.page.setup, .page.settings { width: min(720px, 100%); }
/* ---------- Library ---------- */
.library-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
flex-wrap: wrap;
margin-bottom: 26px;
}
.library-head h1 { margin: 0 0 4px; font-size: 1.5rem; }
.library-sub { margin: 0; color: var(--text-dim); font-size: 0.86rem; }
.library-actions { display: flex; gap: 10px; }
.library-empty {
max-width: 46ch;
margin: 40px auto;
text-align: center;
color: var(--text-dim);
}
.library-empty h2 { margin: 0 0 10px; color: var(--text); font-size: 1.15rem; }
.library-empty p { line-height: 1.65; margin-bottom: 20px; }
.campaign-grid {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 14px;
}
.campaign-card {
display: flex;
flex-direction: column;
gap: 7px;
padding: 16px 18px;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 9px;
transition: border-color 0.15s;
}
.campaign-card:hover { border-color: var(--border-bright); }
.campaign-title { margin: 0; font-size: 1.05rem; font-weight: 600; }
.campaign-title a { color: var(--text); }
.campaign-title a:hover { color: var(--accent-bright); }
.campaign-meta {
margin: 0;
font-size: 0.74rem;
color: var(--text-dim);
font-variant-numeric: tabular-nums;
}
.campaign-snippet {
flex: 1;
margin: 4px 0 8px;
font-family: var(--font-story);
font-size: 0.94rem;
line-height: 1.55;
color: var(--text-dim);
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.campaign-snippet.muted { font-style: italic; opacity: 0.7; }
.campaign-tools { display: flex; gap: 6px; flex-wrap: wrap; }
.campaign-tools .button,
.campaign-tools button {
padding: 4px 11px;
font-size: 0.74rem;
}
/* ---------- Setup ---------- */
.setup h1 { margin: 0 0 6px; font-size: 1.5rem; }
.setup-lede { margin: 0 0 26px; color: var(--text-dim); font-size: 0.9rem; line-height: 1.6; }
.setup-section { margin-bottom: 28px; }
.setup-section h2 {
margin: 0 0 12px;
font-size: 0.7rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--accent-dim);
}
.setup-actions { display: flex; gap: 10px; margin-top: 8px; }
.field { display: flex; flex-direction: column; gap: 5px; margin-bottom: 14px; }
.field > label {
font-size: 0.78rem;
color: var(--text-dim);
}
.field-row { display: flex; gap: 14px; flex-wrap: wrap; }
.field-row > .field { flex: 1; min-width: 190px; }
.field-hint { margin: 2px 0 0; font-size: 0.75rem; line-height: 1.55; color: var(--text-dim); }
/* ---------- Model status ---------- */
.model-badge {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 4px 11px;
font-size: 0.73rem;
color: var(--text-dim);
border: 1px solid var(--border);
border-radius: 14px;
white-space: nowrap;
}
.model-badge:hover { border-color: var(--border-bright); color: var(--text); }
.model-badge-dot {
width: 6px; height: 6px;
border-radius: 50%;
background: var(--accent);
}
.model-badge.bad { color: var(--danger); border-color: var(--danger); }
.model-badge.bad .model-badge-dot { background: var(--danger); }
.model-badge.checking .model-badge-dot { background: var(--text-dim); }
.model-badge-model { color: var(--text-dim); opacity: 0.8; }
.notice {
margin: 0 0 20px;
padding: 14px 16px;
font-size: 0.85rem;
line-height: 1.6;
background: var(--bg-panel);
border: 1px solid var(--border-bright);
border-left: 3px solid var(--accent);
border-radius: 7px;
}
.notice.error { border-left-color: var(--danger); }
.notice strong { display: block; margin-bottom: 6px; }
.notice p { margin: 0 0 8px; color: var(--text-dim); }
.notice-detail { font-size: 0.8rem; color: var(--text-dim); }
.notice-detail.error { color: var(--danger); }
.notice-steps { margin: 8px 0; padding-left: 1.3em; color: var(--text-dim); }
.notice-steps li { margin-bottom: 5px; }
.notice-foot { margin: 10px 0 0 !important; }
.notice code {
padding: 1px 5px;
font-size: 0.86em;
background: var(--bg-input);
border-radius: 3px;
}
.model-choices { list-style: none; display: flex; flex-wrap: wrap; gap: 7px; margin: 10px 0; padding: 0; }
.model-choices button {
padding: 5px 12px;
font-size: 0.8rem;
font-family: var(--font-ui);
background: var(--bg-input);
border: 1px solid var(--border-bright);
border-radius: 5px;
}
.model-choices button:hover { border-color: var(--accent); color: var(--accent-bright); }
.local-only {
margin-bottom: 24px;
padding: 14px 16px;
font-size: 0.85rem;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 7px;
}
.local-only strong { display: block; margin-bottom: 6px; color: var(--accent-bright); }
.local-only p { margin: 0; color: var(--text-dim); line-height: 1.6; }
.settings-section { margin-bottom: 34px; }
.settings-section h2 {
margin: 0 0 6px;
font-size: 0.7rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--accent-dim);
}
.panel-actions { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
.test-result { margin-top: 12px; font-size: 0.83rem; }
.test-result.ok { color: var(--accent); }
.test-result.bad { color: var(--danger); }
.advanced-block {
margin-top: 14px;
padding: 10px 14px;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 7px;
}
.advanced-block > summary {
cursor: pointer;
font-size: 0.8rem;
color: var(--text-dim);
}
.advanced-block > summary:hover { color: var(--text); }
.advanced-block h4 { margin: 10px 0 4px; font-size: 0.74rem; color: var(--text-dim); }
/* M9: the list of database backups already on disk. A filename, a size and a
time — enough to recognise one, and nothing that needs a table. */
.backup-list {
margin: 8px 0 0;
padding: 0;
list-style: none;
font-size: 0.78rem;
}
.backup-list li {
padding: 3px 0;
border-top: 1px solid var(--border);
/* A long filename wraps rather than pushing the panel sideways. */
overflow-wrap: anywhere;
}
.backup-list li:first-child { border-top: none; }
.advanced-block pre {
max-height: 16em;
overflow: auto;
padding: 8px 10px;
font-size: 0.74rem;
background: var(--bg-input);
border-radius: 5px;
}
/* ---------- Utility ---------- */
.sr-only {
position: absolute;
width: 1px; height: 1px;
margin: -1px; padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
/* Visible the moment it is focused, which is the whole point of it. */
.skip-link {
position: absolute;
left: -9999px;
z-index: 100;
padding: 10px 16px;
background: var(--bg-panel);
border: 1px solid var(--accent);
border-radius: 0 0 6px 0;
}
.skip-link:focus { left: 0; top: 0; }
.dim { color: var(--text-dim); }
.linklike {
padding: 0;
color: var(--accent);
background: none;
border: none;
font: inherit;
cursor: pointer;
text-decoration: underline dotted;
text-underline-offset: 3px;
}
.linklike:hover { color: var(--accent-bright); }
/* A setting whose change is worth thinking about before it is made. Amber
rather than red: nothing is destroyed, but the reader should know what the
change does and does not reach. */
.field-warning {
margin: 8px 0 0;
padding: 9px 11px;
font-size: 0.78rem;
line-height: 1.6;
color: var(--text-dim);
background: var(--bg-input);
border-left: 2px solid var(--accent-dim);
border-radius: 0 5px 5px 0;
}
.field-warning strong { color: var(--accent-bright); }
+12 -184
View File
@@ -1,36 +1,8 @@
/* ---------- Play page ---------- */
.play-page {
display: flex;
flex-direction: column;
min-height: calc(100dvh - 56px);
/* Room to scroll the last beat clear of a composer lifted by the keyboard
(see --kb-inset in keyboard.js); 0 on desktop and on browsers that shrink
the layout viewport themselves. Margin, not padding — .page already sets
padding and a longhand here would drop its bottom gutter. */
margin-bottom: var(--kb-inset, 0px);
}
/* Keep the Plot/Memory/Scripts/Insights toggles reachable while the story
scrolls — pin the header just below the 56px top nav. */
.play-page .page-header {
position: sticky;
top: 56px;
z-index: 5;
background: var(--bg);
margin-bottom: 12px;
padding: 12px 0;
border-bottom: 1px solid var(--border);
}
.story {
flex: 1;
font-family: var(--font-story);
font-size: 1.14rem;
line-height: 1.7;
white-space: pre-wrap;
padding-bottom: 16px;
}
/* The head of a windowed transcript: how much story is still above, and the
way back to it. Deliberately quiet — it is a seam in the page, not a
feature — and it sits inside .story so it inherits the story face. */
@@ -56,23 +28,12 @@
color: var(--accent-bright);
border-bottom-color: var(--accent-bright);
}
.story .action {
position: relative;
margin-bottom: 15px;
padding-right: 56px;
animation: action-in 0.4s ease both;
}
@keyframes action-in {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: none; }
}
.story .action.player {
color: var(--player);
border-left: 2px solid rgba(159, 199, 209, 0.35);
padding-left: 14px;
font-style: italic;
}
.story .action.streaming { color: var(--text); }
.story .cursor {
color: var(--accent-bright);
text-shadow: 0 0 8px var(--accent-glow);
@@ -104,27 +65,6 @@
overflow-y: auto;
}
.story .action-tools {
position: absolute;
top: 0;
right: 0;
display: inline-flex;
gap: 4px;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s;
}
.story .action:hover .action-tools { opacity: 1; pointer-events: auto; }
.story .action-tools button {
padding: 1px 8px;
font-size: 0.72rem;
font-family: var(--font-ui);
color: var(--text-dim);
background: var(--bg-panel-glass);
backdrop-filter: blur(6px);
}
.story .action-tools button:hover { color: var(--text); }
/* The takes of one turn: ‹ 2/4 ›. Stays quiet until hovered — it's a footnote
on the message, not part of the prose.
@@ -140,7 +80,7 @@
opacity: 0.45;
transition: opacity 0.15s;
}
.story .action:hover .take-pager,
.turn:hover .take-pager,
.take-pager:focus-within { opacity: 1; }
.take-pager button {
width: 20px;
@@ -174,92 +114,17 @@
color: var(--accent-dim);
}
/* ---------- Branches panel (Phase 14, SP7) ---------- */
/* ---------- Shared panel furniture ---------- */
.panel-empty {
padding: 18px 0;
color: var(--text-dim);
font-size: 0.85rem;
}
.branch-panel { display: flex; flex-direction: column; gap: 14px; }
.branch-intro {
margin: 0;
color: var(--text-dim);
font-size: 0.82rem;
line-height: 1.55;
}
.branch-list { display: flex; flex-direction: column; gap: 8px; }
.branch-row {
display: flex;
flex-direction: column;
gap: 5px;
padding: 9px 11px;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--bg-input);
}
.branch-row.here {
border-color: var(--accent-dim);
background: rgba(212, 169, 78, 0.07);
}
.branch-head { display: flex; align-items: center; gap: 7px; }
.branch-glyph { color: var(--border-bright); font-size: 0.85rem; }
.branch-row.here .branch-glyph { color: var(--accent); }
.branch-name {
font-family: var(--font-story);
font-size: 0.98rem;
color: var(--text);
}
.branch-row.here .branch-name { color: var(--accent-bright); }
.branch-here {
margin-left: auto;
font-size: 0.64rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--accent-dim);
}
.branch-rename {
flex: 1;
min-width: 0;
padding: 3px 7px;
font-family: var(--font-story);
font-size: 0.95rem;
}
.branch-meta {
font-size: 0.72rem;
color: var(--text-dim);
font-variant-numeric: tabular-nums;
}
.branch-tools, .branch-confirm { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; }
.branch-tools button, .branch-confirm button {
padding: 3px 9px;
font-size: 0.72rem;
color: var(--text-dim);
background: transparent;
border: 1px solid var(--border);
}
.branch-tools button:hover:not(:disabled),
.branch-confirm button:hover:not(:disabled) {
color: var(--accent-bright);
border-color: var(--border-bright);
}
.branch-tools button.danger:hover:not(:disabled),
.branch-confirm button.danger:hover:not(:disabled) {
color: var(--danger);
border-color: var(--danger);
}
.branch-tools button:disabled, .branch-confirm button:disabled { opacity: 0.4; cursor: default; }
.branch-confirm span {
font-size: 0.74rem;
color: var(--danger);
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 Points (M4). A Save Point is a name for a position in the story, and
the confirmations are shaped so that restoring does not read as dangerous —
it destroys nothing. 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 {
@@ -366,49 +231,12 @@
}
.action-edit .edit-buttons { display: flex; gap: 8px; margin-top: 6px; }
.play-controls {
position: sticky;
/* Sits on the keyboard's top edge when one is open, on the viewport bottom
otherwise — --kb-inset is 0 unless a virtual keyboard is covering us. */
bottom: var(--kb-inset, 0px);
background: linear-gradient(to top, var(--bg) 75%, transparent);
padding: 18px 0 16px;
border-top: 1px solid var(--border);
}
.turn-buttons { display: flex; gap: 8px; margin-bottom: 10px; }
.turn-buttons button { font-size: 0.78rem; padding: 4px 12px; color: var(--text-dim); }
.turn-buttons button:hover:not(:disabled) { color: var(--text); }
/* The composer, the mode selector and the sticky control bar that used to
live here are gone: M8 replaced them with one input plus a direction
toggle, styled in `story.css`. The `.input-bar { display: flex }` left
behind here overrode the new layout, because this sheet is imported after
that one — which is exactly the hazard the import order comment warns of. */
.input-bar { display: flex; gap: 8px; align-items: flex-end; }
.input-bar input { flex: 1; }
/* Auto-growing action box: starts at one line, grows to ~4, then scrolls. */
.input-bar textarea {
flex: 1;
resize: none;
min-height: 0;
max-height: calc(6em + 20px); /* ~4 lines (1.5em each) + padding/border */
overflow-y: auto;
line-height: 1.5;
}
.mode-select { display: flex; }
.mode-select button {
border-radius: 0;
border-right-width: 0;
padding: 7px 14px;
color: var(--text-dim);
text-transform: capitalize;
}
.mode-select button:first-child { border-radius: 8px 0 0 8px; }
.mode-select button:last-child { border-radius: 0 8px 8px 0; border-right-width: 1px; }
.mode-select button.active {
color: var(--accent-bright);
background: var(--bg-panel);
border-color: var(--accent-dim);
border-right-width: 1px;
box-shadow: inset 0 0 12px rgba(212, 169, 78, 0.08);
}
.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
-249
View File
@@ -1,249 +0,0 @@
/* ---- Form-based schema editor ----
One shape for everything: a section is a panel, every entry inside it is a
`.se-row` tile, and every control wears a `.se-field` caption. Adding a new
kind of entry means reusing those three, not inventing a fourth box. */
.se-section {
margin-bottom: 16px;
padding: 13px 15px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg-panel);
}
.se-section-nested {
margin: 10px 0 0;
padding: 11px 12px;
background: rgba(0, 0, 0, 0.22);
border-radius: 8px;
}
.se-section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 8px;
}
.se-section-title {
font-size: 0.74rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--accent);
}
.se-hint {
margin: -2px 0 10px;
font-size: 0.78rem;
line-height: 1.45;
color: var(--text-dim);
}
.se-empty { color: var(--text-dim); font-size: 0.82rem; padding: 4px 0; }
.se-add, .se-add-sm {
font-size: 0.78rem;
padding: 3px 10px;
white-space: nowrap;
}
.se-add-sm { margin-top: 6px; }
.se-remove {
background: transparent;
border: none;
color: var(--text-dim);
cursor: pointer;
padding: 2px 6px;
font-size: 0.9rem;
line-height: 1;
flex-shrink: 0;
}
.se-remove:hover { color: var(--danger, #e5484d); }
/* Every control in the editor, one look. The global field rule keys off
`input[type="…"]`, so the plain `<input>`s here (keys, descriptions) never
matched it and fell back to browser defaults — that mismatch is most of why
this panel used to read as a pile of unrelated boxes. Everything below is
qualified with `.se` so it outranks that global rule (0,1,1) too. */
/* `:where()` so the exclusion costs no specificity — a plain
`:not([type="checkbox"])` would rank this above the `.se .se-*` overrides
below and swallow every one of them. */
.se input:not(:where([type="checkbox"])), .se textarea, .se select {
width: 100%;
background: var(--bg-input);
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
padding: 5px 8px;
font-size: 0.84rem;
font-family: inherit;
transition: border-color 0.15s, box-shadow 0.15s;
}
/* One entry — a stat, a flag, a milestone. */
.se-row {
padding: 10px 12px;
margin-bottom: 8px;
border: 1px solid var(--border);
border-radius: 8px;
background: rgba(255, 255, 255, 0.018);
transition: border-color 0.15s;
}
.se-row:last-child { margin-bottom: 0; }
.se-row:focus-within { border-color: var(--border-bright); }
/* Wraps because a phone-width row can't fit key + toggle + ✕ side by side, and
an input's default `min-width: auto` would push the ✕ off-screen instead. */
.se-row-top { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-bottom: 8px; }
.se .se-key {
font-family: var(--font-mono, monospace);
font-size: 0.82rem;
min-width: 0;
max-width: 220px;
}
/* Only in the row context — `.se-field` is a *column* flex, where a basis of
140px would set the input's height instead of its width. */
.se .se-row-top .se-key { flex: 1 1 140px; }
.se-field { display: flex; flex-direction: column; gap: 3px; }
.se-field > span {
font-size: 0.66rem;
text-transform: uppercase;
letter-spacing: 0.09em;
color: var(--text-dim);
}
.se-fields {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
align-items: flex-end;
margin-bottom: 8px;
}
.se .se-num input, .se .se-num select { width: 72px; padding: 4px 6px; }
.se .se-kind select { width: 132px; }
.se .se-initial-text input { width: 220px; max-width: 100%; }
.se-check {
display: flex; align-items: center; gap: 5px;
font-size: 0.8rem; color: var(--text-dim);
white-space: nowrap;
}
.se-check input { width: auto; }
/* The global textarea rule also forces min-height:70px, which a 2-row inline
field doesn't want. */
.se .se-area { min-height: 0; line-height: 1.45; resize: vertical; }
.se-sub-head { font-size: 0.7rem; color: var(--text-dim); margin: 4px 0 4px; }
.se-band { display: flex; gap: 6px; align-items: center; margin-bottom: 4px; }
/* Qualified so it outweighs the global input[type="number"] rule (0,1,1) —
as a bare class it lost, and the band bounds rendered full-width. */
.se .se-band-n { width: 64px; padding: 4px 6px; }
.se .se-band-l { flex: 1 1 60px; width: auto; min-width: 0; }
.se-bands { margin-top: 8px; }
/* ---- NPC roster (its own page section) ---- */
.se-npc-grid {
display: grid;
/* min() so a 320px phone gets one column instead of a 340px overflow */
grid-template-columns: repeat(auto-fill, minmax(min(340px, 100%), 1fr));
gap: 14px;
align-items: start;
}
.se-npc {
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg-panel);
overflow: hidden;
}
.se-npc:focus-within { border-color: var(--border-bright); }
.se-npc-head {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-bottom: 1px solid var(--border);
background: linear-gradient(180deg, rgba(212, 169, 78, 0.07), transparent);
}
.se-avatar {
flex-shrink: 0;
width: 34px; height: 34px;
border-radius: 50%;
display: grid; place-items: center;
font-family: var(--font-display);
font-size: 0.8rem;
letter-spacing: 0.02em;
color: #14100a;
background: linear-gradient(150deg, var(--accent-bright), var(--accent-dim));
}
.se-npc-ident { flex: 1; min-width: 0; }
.se .se-npc-name {
padding: 2px 6px;
font-family: var(--font-display);
font-size: 0.95rem;
background: transparent;
border-color: transparent;
}
.se .se-npc-name:focus { background: var(--bg-input); border-color: var(--border-bright); }
.se-npc-addr {
display: block;
margin: 1px 0 0 6px;
font-family: var(--font-mono, monospace);
font-size: 0.7rem;
color: var(--text-dim);
}
.se-npc-count {
flex-shrink: 0;
font-size: 0.7rem;
color: var(--text-dim);
white-space: nowrap;
}
.se-npc-body { padding: 12px; display: flex; flex-direction: column; gap: 10px; }
.se-npc-idfield .se-key { max-width: 100%; }
.ws-report { list-style: none; margin: 4px 0 0; padding: 0; font-size: 0.82rem; }
.ws-report li { padding: 2px 0; color: var(--text); }
/* Inline per-turn change chips under an AI message */
.turn-changes {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.chg {
font-size: 0.74rem;
padding: 2px 8px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--bg-input);
color: var(--text-dim);
font-variant-numeric: tabular-nums;
/* A chip must never be wider than the column it sits in: an NPC stat like
"bandit leader aggression +10" overruns a phone, and the chip row is
inside the story text, which has nothing to scroll. Text may break
mid-word as a last resort, which is what keeps a chip's minimum width
small enough for the flex row to shrink it. */
max-width: 100%;
overflow-wrap: anywhere;
}
.chg-val { overflow-wrap: anywhere; }
/* Only a signed number is held on one line, so the sign never ends up on a
line of its own. The other values a chip can carry are phrases — a refusal
reason, "no change — at its limit", or the whole new value of a free-text
stat — and holding those together set the chip's minimum width to the width
of the phrase, which is what pushed a chip out of the message on a phone. */
.chg-num { white-space: nowrap; }
.chg-up { color: var(--player, #4caf82); border-color: color-mix(in srgb, var(--player, #4caf82) 40%, var(--border)); }
.chg-down { color: var(--danger, #e5484d); border-color: color-mix(in srgb, var(--danger, #e5484d) 40%, var(--border)); }
.chg-flag { color: var(--accent-bright); }
.chg-ms { color: var(--accent-bright); border-color: color-mix(in srgb, var(--accent-bright) 40%, var(--border)); }
/* A refused change, and a clamp that left the value where it started. The
dashed border separates it from the changes that took effect. It is not
styled as an error, because the rules refusing a change is them working. */
.chg-refused {
border-style: dashed;
color: var(--text-dim);
border-color: color-mix(in srgb, var(--danger) 30%, var(--border));
}
/* A clamp that reduced a change but still moved the value. */
.chg-limited { opacity: 0.75; font-style: italic; }
.ws-report code { color: var(--accent-bright); }
.ws-delta { color: var(--player); font-variant-numeric: tabular-nums; }
.ws-flag { color: var(--text-dim); font-style: italic; }
.ws-rejected { color: var(--text-dim); }
.side-panel-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
.side-panel-header h2 {
margin: 0;
font-family: var(--font-display);
font-size: 1.05rem;
color: var(--accent);
}

Some files were not shown because too many files have changed in this diff Show More