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
163 lines
6.0 KiB
Python
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
|