M8: the browser becomes the storyteller

The interface was AI-DnD's with this product's features bolted into it. The
navigation read Home · Adventures · Scenarios · Settings · AI Chat; starting a
story meant first picking a *world*, and making a world meant a JSON stat-schema
form, a story-card table and an art picker. The play screen had a Branches tab.
The input had three modes. Sixteen of the sixteen controls on a two-turn story
had no accessible name — they were single glyphs with a tooltip.

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

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

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

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

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

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

The two defects worth the space:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017HdaXiFbscatQaLS7dJk6b
This commit is contained in:
JesseMarkowitz
2026-09-06 23:31:45 -04:00
co-authored by Claude Opus 5
parent 480414efe0
commit 1ce9972760
89 changed files with 11985 additions and 6532 deletions
+16
View File
@@ -155,6 +155,22 @@ class Adventure(Base):
# and a science-fiction one forbidding faster-than-light travel use the same
# field and the same validator; neither word appears in the application.
campaign_canon: Mapped[dict | None] = mapped_column(JSON, nullable=True)
@property
def canon_rules(self) -> list[str]:
"""The `rules` list alone, which is the half a person writes.
`campaign_canon` also carries `forbidden_status_changes`, a structured
shape the browser has no editor for and does not need one for — a rule
like "nothing dead becomes alive" is expressible as a sentence. So the
API exposes the sentences and leaves the structured half to whatever
wrote it, rather than round-tripping a shape the UI would flatten.
"""
canon = self.campaign_canon
if not isinstance(canon, dict):
return []
rules = canon.get("rules")
return [r for r in rules if isinstance(r, str)] if isinstance(rules, list) else []
# The ${Placeholder} answers collected when this adventure was started, kept
# so "Update from scenario" can re-fill freshly copied scenario text with the
# same values. NULL for adventures created before this column existed.
+18 -10
View File
@@ -381,21 +381,29 @@ class OpenAICompatibleProvider(Provider):
return vectors
def _friendly_http_error(self, status: int, detail: str) -> str:
"""The message a reader sees when the endpoint answers with an error.
M8 rewrote two of these. They were the last user-facing text describing
a hosted deployment this build does not have: a 401 advised checking an
API key, and a 429 explained a shared free tier's daily cap. There is no
API key field — M2 removed it with the cloud providers — and no shared
tier, so both sent a reader looking for a setting that does not exist.
Ollama's own 401 and 429 mean something else entirely.
"""
if status == 401:
return "Authentication failed — check your API key in Settings."
return (
"The endpoint refused the request as unauthorized (HTTP 401). "
"An ordinary local Ollama does not require authentication — "
f"check that {self.base_url} is the endpoint you meant. {detail}"
)
if status == 404:
return (
f"Endpoint or model not found (HTTP 404). Check the endpoint URL and that "
f"model '{self.model}' exists. {detail}"
)
if status == 429:
# OpenRouter's shared free tier has a per-day cap. Distinguish it
# from a short-term burst limit, so the message tells the reader what
# to do.
if "free-models-per-day" in detail:
return (
"The free demo has hit its daily request limit (resets at "
"00:00 UTC). Please try again later."
)
return "The AI is getting too many requests right now — wait a moment and try again."
return (
"The endpoint is refusing further requests for now (HTTP 429). "
"Wait a moment and try again."
)
return f"AI endpoint returned HTTP {status}: {detail}"
+42 -11
View File
@@ -195,20 +195,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 +313,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
+31 -1
View File
@@ -82,8 +82,35 @@ async def with_turn_lock(adventure_id: int, gen):
_active_turns.discard(adventure_id)
#: Openings that mean the reader has already written the subject of the sentence.
#:
#: Matched as whole words, longest first, so "I'm" is recognised before "I".
_FIRST_PERSON = ("i ", "i'm ", "i've ", "i'll ", "i'd ", "my ", "we ", "we're ")
def format_player_input(action_type: str, text: str) -> str:
"""Formats player input the way AI Dungeon does."""
"""Formats player input the way AI Dungeon does — with one M8 correction.
The convention is a `>` marker and second person: typing `look around` in
the old Do mode stored `> You look around.`, which reads correctly and shows
the model whose turn it is.
**M8 broke that assumption and this repairs it.** `BROWSER-UX-SPEC.md` §12
replaced the Do/Say/Story selector with one natural-language field, and §11
tells the reader to write sentences like *"I enter the tavern."* Prefixing
that produced `> You I enter the tavern.` — in the transcript, in the
replayed history, and therefore in the narration, where a small model
imitates it and writes "You I thank her". It was visible in the very first
browser pass of the new composer.
So the prefix is added only when the reader has *not* already written a
subject. First person is left alone; everything else keeps the old
behaviour, and the `>` marker is unchanged in every case, because that is
what actually distinguishes a player turn in the prompt.
Storage is unchanged for text that was already formatted — see
`test_take_parentage.py`, which guards against `> You > You ...`.
"""
text = text.strip()
if action_type == "say":
text = text.strip('"')
@@ -95,6 +122,9 @@ def format_player_input(action_type: str, text: str) -> str:
text = text[4:]
if text and text[-1] not in ".!?…":
text += "."
lowered = text.lower()
if any(lowered.startswith(opening) for opening in _FIRST_PERSON):
return f"> {text}"
return f"> You {text}"
return text # The "story" type is appended as raw text.
+25
View File
@@ -143,6 +143,24 @@ class ScenarioListItem(ORMModel):
class AdventureCreate(BaseModel):
scenario_id: int | None = None
title: Name | None = None
# M8: the opening scene, for a campaign started without a scenario.
#
# A scenario's `prompt` already becomes the campaign's `start` action, and
# this is the same thing said directly. It exists because M8's setup flow
# creates a campaign from a form rather than from a template
# (`BROWSER-UX-SPEC.md` §41), and without it every new campaign opens on a
# blank page — the reader has to invent the situation *and* the first move
# in one box. Ignored when `scenario_id` is given, which already supplies one.
opening: Prose = ""
# M8: the campaign's own rules, as a list of sentences.
#
# The column has existed since migration 82 and both the prompt
# (`context/builder._canon_section`) and the state validator
# (`narrative/apply`) already read it — it simply had no way in from the
# browser, so a fixture had to write it with SQL. This is the highest
# authority in the campaign, which is exactly why a person setting one up
# needs to be able to state it.
canon_rules: list[Name] = []
# The `${Placeholder}` values collected from the player at the start, which
# is the AI Dungeon behavior.
placeholders: dict[str, str] = {}
@@ -165,6 +183,10 @@ class AdventureUpdate(BaseModel):
persona_name: PersonaName | None = None
persona_pronouns: PersonaPronouns | None = None
persona_desc: Prose | None = None
# M8. See `AdventureCreate.canon_rules`. Editable after setup because canon
# is the thing a reader most often gets wrong first and needs to correct —
# "resurrection is impossible" is easier to write once the story has tried it.
canon_rules: list[Name] | None = None
class AdventureRefresh(BaseModel):
@@ -456,6 +478,9 @@ class AdventureOut(ORMModel):
persona_name: str
persona_pronouns: str
persona_desc: str
# M8. Read from the `canon_rules` property on the model, which pulls the
# sentence list out of the stored `campaign_canon` document.
canon_rules: list[str] = []
created_at: datetime
updated_at: datetime
story_cards: list[StoryCardOut] = []
+16 -7
View File
@@ -1476,18 +1476,27 @@ def test_h06_h07_imported_active_content_is_served_as_inert_text(client):
assert report_response.headers["x-content-type-options"] == "nosniff"
# And the browser side never renders it as HTML. Asserted against the source
# of the two components that display imported text, because that is where
# the property would be lost — a `dangerouslySetInnerHTML` added to either
# is what turns every assertion above into decoration. A real browser
# exercises the same two components; this fails at build time instead of
# waiting for someone to run one.
# of every component that displays imported or narrator text, because that
# is where the property would be lost — one `dangerouslySetInnerHTML` turns
# every assertion above into decoration.
#
# M8 renamed the Insights panel to `ContextPanel.jsx` and added
# `markdown.jsx`, which renders narrator prose. The renderer is the newest
# and largest way this property could be lost, so it is guarded here too;
# `frontend/src/markdown.test.jsx` covers its behaviour, and this covers the
# one line that would make that behaviour irrelevant.
import pathlib
frontend = pathlib.Path(__file__).resolve().parents[2] / "frontend" / "src"
for name in ("pages/Play/panels/KnowledgePanel.jsx",
"pages/Play/panels/InsightsPanel.jsx"):
"pages/Play/panels/ContextPanel.jsx",
"markdown.jsx"):
text = (frontend / name).read_text()
assert "dangerouslySetInnerHTML" not in text, name
# The prop as it would actually be written, not the bare word: these
# files discuss the hazard in their own comments, and a test that
# cannot tell an explanation from a use would forbid documenting it.
assert "dangerouslySetInnerHTML=" not in text, name
assert "dangerouslySetInnerHTML:" not in text, name
assert "innerHTML" not in text.replace("document.body.innerHTML='owned'", ""), name
+202
View File
@@ -0,0 +1,202 @@
"""M8: the two fields the streamlined setup flow added, and what they must not do.
`BROWSER-UX-SPEC.md` §41 replaced "pick a scenario, then fill in its
placeholders" with a form. Two things had to reach the API for that to work, and
both are narrow by design (`BUILD-MILESTONES.md` M8, §39 of the brief):
opening the campaign's first scene, so a new campaign does not open on
a blank page. It builds the same `start` node a scenario's
prompt does, by the same code path.
canon_rules a read/write view onto the `rules` list inside the existing
`campaign_canon` document, which has had no API at all since
the column was added in migration 82.
Neither adds a column. `test_knowledge_migration.py` and the M8 report's
migration proof cover the schema claim; these cover the behaviour.
"""
import pytest
from fastapi import Depends
from fastapi.testclient import TestClient
from app import auth, limits, models
from app.database import Base, SessionLocal, engine, get_db
from app.main import app
OPENING = "You sit at a shared table in the Crooked Lantern Tavern."
@pytest.fixture()
def client(monkeypatch):
"""The suite's convention: create the schema, own a user, drop it after.
The first version of this fixture just wrapped `TestClient(app)`. It passed
in isolation and failed ten ways in the full suite, because the tests share
one database and every other module creates and drops the schema around
itself — so this file inherited whatever the previous module had left, and
had no user of its own for `auth.get_current_user` to find.
"""
Base.metadata.create_all(bind=engine)
setup = SessionLocal()
user = models.User(is_guest=False, email="m8setup@example.com")
setup.add(user)
setup.flush()
setup.add(models.Settings(user_id=user.id, model="test-model"))
setup.commit()
user_id = user.id
setup.close()
monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None)
def _current_user(db=Depends(get_db)):
return db.get(models.User, user_id)
app.dependency_overrides[auth.get_current_user] = _current_user
c = TestClient(app)
try:
yield c
finally:
app.dependency_overrides.clear()
Base.metadata.drop_all(bind=engine)
def _campaign(client, **body):
r = client.post("/api/adventures", json=body)
assert r.status_code == 201, r.text
return r.json()
# ---------------------------------------------------------------- opening ---
def test_the_opening_becomes_the_campaigns_first_scene(client):
adv = _campaign(client, title="With opening", opening=OPENING)
assert [(a["type"], a["text"]) for a in adv["actions"]] == [("start", OPENING)]
# `action_count` is computed on read, so the create response reports 0 —
# for a scenario-made campaign too, and it has always done so. The browser
# navigates to the campaign and re-reads, which is the surface asserted
# here and the one a reader actually sees.
fetched = client.get(f"/api/adventures/{adv['id']}").json()
assert fetched["action_count"] == 1
assert [(a["type"], a["text"]) for a in fetched["actions"]] == [("start", OPENING)]
def test_the_opening_is_not_duplicated(client):
adv = _campaign(client, title="Once", opening=OPENING)
again = client.get(f"/api/adventures/{adv['id']}").json()
assert [a["text"] for a in again["actions"]].count(OPENING) == 1
assert again["action_count"] == 1
def test_the_opening_is_placed_on_the_tree_like_any_other_node(client):
"""It must not bypass head/history semantics.
A `start` node that was not placed on the tree, or carried no state
snapshot, would break Undo and retry at the first turn — which is exactly
where a new reader meets them.
"""
adv = _campaign(client, title="Placed", opening=OPENING)
db = SessionLocal()
try:
row = (db.query(models.Action)
.filter(models.Action.adventure_id == adv["id"]).one())
assert row.depth == 0
assert row.branch_id is not None
assert row.parent_id is None
finally:
db.close()
# And the head is at it: there is nothing before the opening to undo to.
assert adv["can_undo"] is False
assert adv["can_redo"] is False
assert client.post(f"/api/adventures/{adv['id']}/undo").status_code >= 400
def test_a_campaign_without_an_opening_still_starts_empty(client):
adv = _campaign(client, title="Blank")
assert adv["actions"] == []
def test_a_scenario_prompt_takes_precedence_and_is_never_doubled(client):
"""Both routes build the same node, so only one of them may fire."""
sc = client.post("/api/scenarios",
json={"title": "S", "prompt": "A scenario opening."}).json()
adv = _campaign(client, scenario_id=sc["id"], opening=OPENING)
assert [a["text"] for a in adv["actions"]] == ["A scenario opening."]
legacy = _campaign(client, scenario_id=sc["id"])
assert [a["text"] for a in legacy["actions"]] == ["A scenario opening."]
def test_the_opening_survives_export_and_import(client):
adv = _campaign(client, title="Round trip", opening=OPENING)
bundle = client.get(f"/api/adventures/{adv['id']}/export").json()
restored = client.post("/api/adventures/import", json=bundle).json()
assert [a["text"] for a in restored["actions"]] == [OPENING]
# ------------------------------------------------------------ canon_rules ---
def test_canon_rules_round_trip_and_blank_lines_are_dropped(client):
adv = _campaign(client, title="Canon",
canon_rules=["Resurrection is impossible.", " ", "Magic exists."])
assert adv["canon_rules"] == ["Resurrection is impossible.", "Magic exists."]
patched = client.patch(f"/api/adventures/{adv['id']}",
json={"canon_rules": ["Only one rule now."]}).json()
assert patched["canon_rules"] == ["Only one rule now."]
cleared = client.patch(f"/api/adventures/{adv['id']}",
json={"canon_rules": []}).json()
assert cleared["canon_rules"] == []
def test_editing_canon_preserves_the_structured_half_it_has_no_editor_for(client):
"""`campaign_canon` also holds `forbidden_status_changes`.
The browser edits sentences and has no editor for the structured shape, so
writing the sentences must not discard it — otherwise importing a bundle
that carries one and then touching canon in the UI would silently drop a
rule the validator enforces.
"""
adv = _campaign(client, title="Structured")
db = SessionLocal()
try:
row = db.get(models.Adventure, adv["id"])
row.campaign_canon = {
"rules": ["R1"],
"forbidden_status_changes": [{"from": "dead", "to": "alive"}],
}
db.commit()
finally:
db.close()
assert client.get(f"/api/adventures/{adv['id']}").json()["canon_rules"] == ["R1"]
client.patch(f"/api/adventures/{adv['id']}", json={"canon_rules": ["R2", "R3"]})
db = SessionLocal()
try:
stored = db.get(models.Adventure, adv["id"]).campaign_canon
assert stored["rules"] == ["R2", "R3"]
assert stored["forbidden_status_changes"] == [{"from": "dead", "to": "alive"}]
finally:
db.close()
def test_canon_is_untouched_by_an_unrelated_patch(client):
adv = _campaign(client, title="Untouched", canon_rules=["A rule."])
renamed = client.patch(f"/api/adventures/{adv['id']}",
json={"title": "Renamed"}).json()
assert renamed["canon_rules"] == ["A rule."]
assert renamed["title"] == "Renamed"
def test_canon_reaches_the_prompt_as_the_campaigns_own_rules(client):
"""The point of exposing it: what is written here is what the narrator is told."""
adv = _campaign(client, title="Prompted",
canon_rules=["Resurrection is impossible."])
report = client.get(f"/api/adventures/{adv['id']}/context").json()
canon = next((s["text"] for s in report["sections"]
if s["label"] == "campaign_canon"), "")
assert "Resurrection is impossible." in canon
+18 -40
View File
@@ -1471,46 +1471,24 @@ def test_the_save_point_count_matches_what_blocks_the_deletion(client):
f"/api/adventures/{client.adv_id}/branches/{middle_branch}"
).status_code == 409
def test_both_branch_delete_surfaces_explain_the_save_point_rule():
"""The rule must be visible in every view the deletion is reachable from.
A source-level assertion, because the project has no frontend test runner
(M8). `test_offline_assets.py` reads the frontend the same way, for the same
reason: the check is worth having now, and it is honest about what it is —
it proves the wiring is in the build, not that a user saw it. The browser
smoke test performed at closeout is what proves the rendering.
Two files, because the branch list and the tree overlay each render their
own delete control, and a rule that held in one of them would not be a rule.
"""
from pathlib import Path
repo = Path(__file__).resolve().parents[2]
views = {
"the branch panel":
repo / "frontend/src/pages/Play/panels/BranchPanel.jsx",
"the tree overlay":
repo / "frontend/src/BranchMap.jsx",
}
for where, path in views.items():
source = path.read_text(encoding="utf-8")
assert "savePointsUnder" in source, (
f"{where} does not count the Save Points that protect a branch"
)
# The Delete control is disabled while Save Points protect the subtree,
# and says why rather than failing silently on the server.
assert "protecting > 0" in source, (
f"{where} does not disable Delete while Save Points protect the branch"
)
assert "deleting a Save Point deletes no story" in source, (
f"{where} does not tell the user how to proceed"
)
# The user-facing copy must not explain itself in schema terms.
for jargon in ("cascade", "foreign key", "foreign-key", "ON DELETE"):
assert jargon.lower() not in source.lower(), (
f"{where} uses implementation jargon in user-facing copy: {jargon}"
)
# The browser copy for deleting and restoring a Save Point was asserted here,
# by reading `SavePointPanel.jsx` as text. That check is gone, and this note is
# what replaced it.
#
# It existed because the project had no frontend test runner and the wording is
# load-bearing: a reader who believes Restore destroys their later story will
# not press it. M8 supplied the runner, and
# `frontend/src/pages/Play/panels/panels.test.jsx` now renders both
# confirmations and reads what they actually say — which is the thing this was
# approximating, done properly.
#
# It was also becoming unsound. JSX wraps prose across lines, so a substring
# match on a sentence broke on reflow rather than on a change of meaning, and
# the same check forbade the words "branch" and "fork" in a file whose own
# comments explain why those words are avoided.
#
# The server-side rule it protected — deleting a branch a Save Point is kept on
# is refused — is unchanged and tested above.
def test_creating_a_save_point_takes_the_campaigns_turn_lock(client):
+80
View File
@@ -474,3 +474,83 @@ def test_naming_a_take_that_is_already_the_story_just_plays_on(client):
_play(client, "carry on", after_id=live.id)
assert _branch_count(client.adv_id) == before
def test_first_person_input_is_not_prefixed_with_you():
"""M8. `BROWSER-UX-SPEC.md` §11-12: one field, and the reader writes "I …".
The AI Dungeon convention prefixes a player action with `> You `, which was
right when the Do mode asked for a bare verb phrase. With one
natural-language field it produced `> You I enter the tavern.` — in the
transcript, in the replayed history, and so in the narration, where a small
model imitated it and wrote "You I thank her". Found in the first browser
pass of the M8 composer.
Tested against the shared normalizer rather than a rendered component,
because every surface — storage, transcript, replayed history, export —
reads the result of this one function.
The `>` marker is what distinguishes a player turn in the prompt, so it is
unchanged in every case; only the redundant subject is dropped.
"""
from app.routers.adventures.turns import format_player_input as fmt
# --- The §11 examples, verbatim from the specification ---
assert fmt("do", "I enter the tavern.") == "> I enter the tavern."
assert fmt("do", "I ask Mara about Edrin.") == "> I ask Mara about Edrin."
assert fmt("do", "I wait quietly and watch the room.") == (
"> I wait quietly and watch the room.")
# --- No duplicate subject, in any first-person phrasing ---
for text in ("I walk into the tavern", "I'm going to knock", "I've seen this before",
"I'll wait", "I'd rather not", "My hand finds the key",
"We head north", "We're leaving"):
out = fmt("do", text)
assert "You I" not in out, f"duplicate subject in {out!r}"
assert "You My" not in out, f"duplicate subject in {out!r}"
assert "You We" not in out, f"duplicate subject in {out!r}"
assert out.startswith("> "), f"lost the player-turn marker in {out!r}"
# --- The legacy bare action still normalizes, which is deliberate ---
assert fmt("do", "open the door") == "> You open the door."
assert fmt("do", "look around") == "> You look around."
# And an explicit "You ..." is de-duplicated rather than doubled.
assert fmt("do", "You leave the tavern") == "> You leave the tavern."
# --- Dialogue and out-of-character direction are untouched ---
assert fmt("say", "Have you seen Edrin?") == '> You say "Have you seen Edrin?"'
assert fmt("say", "I think he went north") == '> You say "I think he went north."'
assert fmt("story", "Keep this scene tense, but do not start a fight yet.") == (
"Keep this scene tense, but do not start a fight yet.")
# --- The marker survives, and is never doubled ---
for kind in ("do", "say"):
assert fmt(kind, "I move").startswith("> ")
assert "> > " not in fmt("do", "I move")
def test_normalized_player_text_reaches_history_exactly_once(client):
"""The normalization must survive into the replayed context, unduplicated.
A `format_player_input` that is correct but applied twice, or correct in
storage and re-prefixed on the way into the prompt, would put "You I ..."
back in front of the model — which is the thing the defect was about. So
this asserts on the assembled prompt, not on the stored row.
"""
_play(client, "I enter the tavern.")
_play(client, "I ask Mara about Edrin.")
stored = [a["text"] for a in
client.get(f"/api/adventures/{client.adv_id}/actions").json()["actions"]]
player_rows = [t for t in stored if t.startswith(">")]
assert player_rows, stored
for row in player_rows:
assert "You I" not in row, row
assert row.count(">") == 1, row
report = client.get(f"/api/adventures/{client.adv_id}/context").json()
prompt = "\n".join(s["text"] for s in report["sections"])
assert "You I " not in prompt, "the model's context was polluted with 'You I ...'"
# Each player line appears once, with its marker, in the replayed history.
for row in player_rows:
assert prompt.count(row) == 1, f"{row!r} appears {prompt.count(row)} times"