Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44edece67e | ||
|
|
1ce9972760 |
@@ -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
@@ -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.)
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
[](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)
|
||||
```
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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()}
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
@@ -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 \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
|
||||
@@ -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
@@ -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."]
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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
|
||||
Generated
+2352
-15
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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" />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 /> },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
@@ -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. `` 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 }
|
||||
@@ -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={''} />,
|
||||
)
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
+400
-582
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 }
|
||||
@@ -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.'],
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -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 & 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.<id>.<stat></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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user