"""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