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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qyn3oRd4D6pi72nKBG725B
2026-09-07 01:55:45 -04:00

163 lines
6.0 KiB
Python

"""The pre-played adventure every new guest is given: app/starter.py.
The starter is a shipped export bundle, so the two things that can break it are
the file and the import path. A bundle that no longer plans, or a story trimmed
to a state a player cannot continue from, both leave the guest worse off than an
empty account would.
python -m pytest tests/test_starter_adventure.py -v
"""
import json
import pytest
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker
from app import bundle, models, seed, starter
from app.database import Base
from app.migrations import bootstrap
@pytest.fixture()
def db(tmp_path):
engine = create_engine(f"sqlite:///{tmp_path/'t.db'}",
connect_args={"check_same_thread": False})
@event.listens_for(engine, "connect")
def _fk(dbapi_connection, _record):
cur = dbapi_connection.cursor()
cur.execute("PRAGMA foreign_keys=ON")
cur.close()
bootstrap(engine)
Base.metadata.create_all(bind=engine)
session = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)()
yield session
session.close()
@pytest.fixture()
def guest(db):
user = models.User(is_guest=True)
db.add(user)
db.commit()
return user
def payload() -> dict:
return json.loads(starter.STARTER_FILE.read_text(encoding="utf-8"))
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.
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 in bundle.READABLE
story = bundle.plan(data, version)
assert story["nodes"]
def test_a_guest_gets_the_adventure_and_owns_it(db, guest):
adventure = starter.give(db, guest)
db.commit()
assert adventure is not None
assert adventure.user_id == guest.id
assert "Pokemon" in adventure.title
assert len(adventure.actions) == len(payload()["actions"])
def test_the_story_cards_come_with_it(db, guest):
"""A copy without the cards would drop out of character on the next turn."""
adventure = starter.give(db, guest)
db.commit()
assert {c.name for c in adventure.story_cards} == {
c["name"] for c in payload()["storyCards"]
}
def test_the_turns_carry_what_the_engine_recorded(db, guest):
"""The point of shipping a played story: the summaries have chips in them.
An applied change, a refused one, and a milestone all appear in the first
two exchanges, which is what a visitor sees before spending a demo turn.
"""
adventure = starter.give(db, guest)
db.commit()
chips = [chip for action in adventure.actions for chip in action.world_changes]
kinds = {chip["kind"] for chip in chips}
assert "stat" in kinds
assert "rejected" in kinds
assert "milestone" in kinds
def test_the_state_left_behind_can_be_played_from(db, guest):
"""The opponent has to be on the field with HP, or every hit is refused.
A Pokemon sent in at 0 HP sits at the floor of `active_hp`, so the engine
refuses each later change and the story stops moving. That state reached a
playtest once, and it must not be what a guest inherits.
"""
adventure = starter.give(db, guest)
db.commit()
milo = adventure.world_state["npc"]["milo"]
assert milo["active_hp"] > 0
assert milo["active_pokemon"]
assert adventure.actions[-1].world_state_after["npc"]["milo"] == milo
def test_a_missing_file_costs_the_guest_nothing(db, guest, monkeypatch):
"""A packaging mistake must not stop an account from being created."""
monkeypatch.setattr(starter, "STARTER_FILE", starter.STARTER_FILE.with_name("gone.json"))
assert starter.give(db, guest) is None
def test_a_broken_bundle_leaves_no_half_written_adventure(db, guest, monkeypatch):
"""The savepoint: a failure partway through discards the rows it wrote."""
monkeypatch.setattr(bundle, "materialize",
lambda *a, **k: (_ for _ in ()).throw(RuntimeError("boom")))
assert starter.give(db, guest) is None
db.commit()
assert db.query(models.Adventure).count() == 0
def test_the_copy_inherits_the_demo_scenario_art(db, guest):
"""An adventure has no cover art of its own; it inherits the scenario's.
A bundle carries no scenario id, so the link is made by title. Without it
the starter card shows a monogram while the demo it came from shows its
artwork.
"""
title = payload()["scenarioTitle"]
data = json.loads((seed.SEED_DIR / "05-league-championship.json").read_text(encoding="utf-8"))
# The demo the copy came from, as the seeder would have written it. The
# seeder itself opens its own session against the app's engine, so it
# cannot be pointed at this fixture's database.
assert data["title"] == title, "the starter names a scenario no seed file ships"
db.add(models.Scenario(user_id=None, is_public=True, title=title, image=data["image"]))
db.commit()
adventure = starter.give(db, guest)
db.commit()
scenario = seed.find_seeded(db, title)
assert scenario is not None and scenario.image
assert adventure.scenario_id == scenario.id
def test_a_missing_demo_scenario_only_costs_the_art(db, guest):
"""Nothing seeds the scenarios in this fixture, so the link finds nothing."""
adventure = starter.give(db, guest)
db.commit()
assert adventure is not None
assert adventure.scenario_id is None