Files
interactive-story/backend/app/starter.py
parththakkar106andClaude Opus 5 c2d3f0d8b9 Show the starter adventure the artwork of the demo it came from
An adventure has no cover art of its own and inherits its scenario's, and a
bundle carries no scenario id, because an id means nothing in another database.
The starter card therefore fell back to a monogram tile while the demo beside
it showed the Pokeball.

The starter file names its source under `scenarioTitle`, and the copy is linked
to the seeded scenario with that title. If no seed answers to the name, the
adventure keeps a NULL `scenario_id`, which is the state every imported bundle
is in and costs only the artwork.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PacdRuPXSkQQy4ZYdH32hF
2026-08-28 19:19:23 +05:30

93 lines
3.7 KiB
Python

"""Gives every new guest a short adventure that is already played.
A guest arriving on an empty account has nothing to look at, and the demo turns
are limited, so learning what the app does used to cost one of them. This copies
a small pre-played adventure into the new account instead. It opens on a story
with real turns in it, and the turn summaries show what the world-state engine
records: the changes it applied, and the changes it refused.
The file in `starter_data/` is an ordinary export bundle, produced by
`GET /api/adventures/{id}/export` and trimmed to its first two exchanges. To
replace it, play a new adventure, export it, and overwrite the file. Nothing
else here knows what the story contains.
The copy is the guest's own from the first moment: they can edit it, branch it,
delete it, or export it, and nothing links it back to the file. A returning
guest is not given a second one, because this runs only where a guest row is
created.
"""
import json
import logging
from pathlib import Path
from sqlalchemy.orm import Session
from . import bundle, models, seed
logger = logging.getLogger(__name__)
STARTER_FILE = Path(__file__).resolve().parent / "starter_data" / "pokemon-league.json"
def _load() -> dict | None:
"""Returns the starter bundle, or None when the file is missing or invalid.
The file ships with the server, so a failure here is a packaging mistake
rather than bad input. It is still tolerated: a guest with no starter
adventure can play, and a guest who cannot be created cannot.
"""
try:
return json.loads(STARTER_FILE.read_text(encoding="utf-8"))
except (OSError, ValueError):
logger.exception("Starter adventure is missing or unreadable; skipping it.")
return None
def give(db: Session, user: models.User) -> models.Adventure | None:
"""Copies the starter adventure into `user`, and returns it.
The caller commits. This writes rows and does not commit them, so a guest
and their first adventure land in one transaction: an account is never left
half-populated.
Failures are logged and swallowed. The starter is a convenience, and losing
it must not cost the visitor their session.
"""
payload = _load()
if payload is None:
return None
try:
# A savepoint, so a failure halfway through writing the tree discards
# only the starter's rows. Without it the caller's next commit would
# 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)
_link_scenario(db, adventure, payload)
return adventure
except Exception:
logger.exception("Could not give user %s the starter adventure.", user.id)
return None
def _link_scenario(db: Session, adventure: models.Adventure, payload: dict) -> None:
"""Points the copy at the demo scenario it came from, when that seed exists.
A bundle does not carry a scenario id, because the id is local to one
database and an export outlives it. The link matters anyway: an adventure
has no cover art of its own, and inherits the scenario's. Without it the
starter shows a monogram tile while the demo it came from shows its own
artwork.
The name is read from `scenarioTitle` in the file. If no seeded scenario
answers to it, the adventure keeps a NULL `scenario_id`, which is the same
state an imported bundle is in and costs only the artwork.
"""
title = str(payload.get("scenarioTitle") or "").strip()
if not title:
return
scenario = seed.find_seeded(db, title)
if scenario is not None:
adventure.scenario_id = scenario.id