Files
interactive-story/backend/app/seed.py
T
parththakkar106andClaude Opus 5 57c24a07d8 Redesign the library UI and add scenario cover art
Rework the home page into a single landing surface and give the app a
consistent visual language, chosen as "illuminated tome" over two other
pitched directions because it builds on the existing Cinzel + gold identity
instead of replacing it.

Home is now Continue (up to 4 in-progress stories, each showing where you
left off) over a scenario shelf, each section with a "See all" link. The
full adventure list moves to /adventures.

Scenario cover art has three tiers, in precedence order: an uploaded picture
(downscaled client-side to 400px WebP before storing), an emoji, or gradient
art generated from a hash of the title so no card is ever an empty box.
Adventures inherit their scenario's art. Images live in the row rather than
on disk because Render's free tier has no persistent volume, and it keeps
export bundles self-contained; list responses carry a cacheable
/api/scenarios/{id}/image URL rather than the base64.

Also: ambient drifting motes behind the app, loading skeletons, staggered
card entrance, ornamental scene breaks and a drop cap in the story, a
"Weaving" thinking indicator, and a toast system replacing every alert().

Two fixes found along the way:

- Importing a scenario bundle with no "tags" key returned a 500. Column
  defaults are not applied until flush, so the attribute was still None
  when the width clamp sliced it.
- Anything meaning "the story's latest narration" was missing action type
  "start", which is the only text a freshly created adventure has, so new
  adventures looked empty. Collected as NARRATION_TYPES.

Migrations 30 and 31 add scenarios.image and scenarios.icon; both are
additive with a '' default and were verified against a database stamped at
29. vite.config.js now reads AIDND_API_PORT so the recurring port-8000
clash with another local app needs no file edit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FGY1yvzSeKgTtRfeVtDmx
2026-07-26 13:17:46 +05:30

173 lines
6.1 KiB
Python

"""Seed public demo scenarios on startup.
Every JSON file in ``seed_data/`` describes one demo scenario in the same
model-native shape the export endpoint produces. Seeded scenarios have a NULL
owner and ``is_public=True``, so every visitor (including guests) sees them and
can start an adventure from them, while nobody can edit them. Starting an
adventure copies the scenario's story cards and scripts into the adventure, so
the seeded scripts run for guests too.
Seed files are the source of truth for demo content: a scenario is inserted if
missing, and reconciled in place when a seed file's content changes (so edits
ship on the next deploy). When a seed already matches, nothing is written, so
this stays cheap to run on every boot. Existing adventures already started from
a demo keep their own copied cards/scripts and are unaffected — only new
adventures pick up the updated content.
"""
import json
import logging
from pathlib import Path
from sqlalchemy.engine import Engine
from . import models
from .database import SessionLocal
logger = logging.getLogger(__name__)
SEED_DIR = Path(__file__).resolve().parent / "seed_data"
_SCALARS = ("description", "prompt", "memory", "authors_note", "ai_instructions", "tags",
"image", "icon")
_CARD_FIELDS = ("type", "name", "keys", "entry", "notes")
_SCRIPT_FIELDS = ("name", "library_js", "input_js", "context_js", "output_js")
def seed_public_scenarios(engine: Engine) -> None:
if not SEED_DIR.is_dir():
return
files = sorted(SEED_DIR.glob("*.json"))
if not files:
return
db = SessionLocal()
try:
changed = 0
for path in files:
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
logger.warning("Skipping seed file %s: %s", path.name, exc)
continue
title = (data.get("title") or "").strip()
if not title:
continue
existing = (
db.query(models.Scenario)
.filter(
models.Scenario.title == title,
models.Scenario.user_id.is_(None),
models.Scenario.is_public.is_(True),
)
.first()
)
if existing is None:
_insert_scenario(db, data)
changed += 1
elif not _matches(existing, data):
_update_scenario(db, existing, data)
changed += 1
if changed:
db.commit()
logger.info("Seeded/updated %d public demo scenario(s).", changed)
except Exception:
db.rollback()
# A seed failure must never take the app down; log and carry on.
logger.exception("Seeding public scenarios failed; continuing without them.")
finally:
db.close()
def _card_tuple(source, get) -> tuple:
return tuple(get(source, f) for f in _CARD_FIELDS)
def _script_tuple(source, get) -> tuple:
return tuple(get(source, f) for f in _SCRIPT_FIELDS)
def _matches(scenario: models.Scenario, data: dict) -> bool:
"""True when the DB scenario already equals the seed file, so we can skip
the write and avoid churning rows on every boot."""
if any(getattr(scenario, f) != data.get(f, "") for f in _SCALARS):
return False
if (scenario.stat_schema or None) != (data.get("stat_schema") or None):
return False
have_cards = sorted(_card_tuple(c, lambda o, f: getattr(o, f)) for c in scenario.story_cards)
want_cards = sorted(
_card_tuple(c, lambda o, f: o.get(f, ""))
for c in (data.get("story_cards") or []) if isinstance(c, dict)
)
if have_cards != want_cards:
return False
have_scripts = sorted(_script_tuple(s, lambda o, f: getattr(o, f)) for s in scenario.scripts)
want_scripts = sorted(
_script_tuple(s, lambda o, f: (o.get(f, "") or ("Script" if f == "name" else "")))
for s in (data.get("scripts") or []) if isinstance(s, dict)
)
return have_scripts == want_scripts
def _insert_scenario(db, data: dict) -> None:
scenario = models.Scenario(user_id=None, is_public=True, title=data.get("title", ""))
_apply_scalars(scenario, data)
db.add(scenario)
db.flush()
_populate_children(db, scenario, data)
def _update_scenario(db, scenario: models.Scenario, data: dict) -> None:
_apply_scalars(scenario, data)
# Replace child content wholesale — demo content is server-owned and cheap
# to rebuild, and this keeps the scenario row (and adventure FKs) intact.
for card in list(scenario.story_cards):
db.delete(card)
for script in list(scenario.scripts):
db.delete(script)
scenario.scripts = []
db.flush()
_populate_children(db, scenario, data)
def _apply_scalars(scenario: models.Scenario, data: dict) -> None:
for field in _SCALARS:
setattr(scenario, field, data.get(field, ""))
# Phase 12: RPG world-state template (a JSON dict, not a scalar string).
scenario.stat_schema = data.get("stat_schema") or None
def _populate_children(db, scenario: models.Scenario, data: dict) -> None:
for card in data.get("story_cards") or []:
if not isinstance(card, dict):
continue
db.add(
models.StoryCard(
scenario_id=scenario.id,
type=card.get("type", ""),
name=card.get("name", ""),
keys=card.get("keys", ""),
entry=card.get("entry", ""),
notes=card.get("notes", ""),
)
)
for item in data.get("scripts") or []:
if not isinstance(item, dict):
continue
script = models.Script(
user_id=None,
name=item.get("name", "Script"),
description=item.get("description", ""),
library_js=item.get("library_js", ""),
input_js=item.get("input_js", ""),
context_js=item.get("context_js", ""),
output_js=item.get("output_js", ""),
)
db.add(script)
db.flush()
scenario.scripts.append(script)