Seed public demo scenarios on startup
Idempotent seeder inserts public, unowned demo scenarios from backend/app/seed_data/*.json if a public scenario with the same title is missing, so a fresh Neon database (and any clone) gets demo content guests can play immediately. Ships two scenarios: the Sunken Crypt of Vharos (all four script hooks) and Signal from the Derelict (sci-fi, context-engine focused). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TEpWMjnfPqzZ13nPMoGhs5
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
68c165585c
commit
c96b8cdf2f
@@ -11,8 +11,10 @@ from .database import engine
|
|||||||
from .limits import BodySizeLimitMiddleware
|
from .limits import BodySizeLimitMiddleware
|
||||||
from .migrations import bootstrap
|
from .migrations import bootstrap
|
||||||
from .routers import adventures, auth, debug, scenarios, scripts, settings, story_cards
|
from .routers import adventures, auth, debug, scenarios, scripts, settings, story_cards
|
||||||
|
from .seed import seed_public_scenarios
|
||||||
|
|
||||||
bootstrap(engine)
|
bootstrap(engine)
|
||||||
|
seed_public_scenarios(engine)
|
||||||
|
|
||||||
# Production serves the SPA same-origin, so CORS only matters for the Vite dev
|
# Production serves the SPA same-origin, so CORS only matters for the Vite dev
|
||||||
# server; AIDND_CORS_ORIGINS overrides for any other cross-origin setup.
|
# server; AIDND_CORS_ORIGINS overrides for any other cross-origin setup.
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"""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.
|
||||||
|
|
||||||
|
The seed is idempotent: a scenario is inserted only if no public, unowned
|
||||||
|
scenario with the same title already exists, so it is safe to run on every boot
|
||||||
|
and it re-heals a database that lost its demo 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"
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
created = 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
|
||||||
|
|
||||||
|
exists = (
|
||||||
|
db.query(models.Scenario)
|
||||||
|
.filter(
|
||||||
|
models.Scenario.title == title,
|
||||||
|
models.Scenario.user_id.is_(None),
|
||||||
|
models.Scenario.is_public.is_(True),
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if exists is not None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
_insert_scenario(db, data)
|
||||||
|
created += 1
|
||||||
|
|
||||||
|
if created:
|
||||||
|
db.commit()
|
||||||
|
logger.info("Seeded %d public demo scenario(s).", created)
|
||||||
|
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 _insert_scenario(db, data: dict) -> None:
|
||||||
|
scenario = models.Scenario(
|
||||||
|
user_id=None,
|
||||||
|
is_public=True,
|
||||||
|
title=data.get("title", ""),
|
||||||
|
description=data.get("description", ""),
|
||||||
|
prompt=data.get("prompt", ""),
|
||||||
|
memory=data.get("memory", ""),
|
||||||
|
authors_note=data.get("authors_note", ""),
|
||||||
|
ai_instructions=data.get("ai_instructions", ""),
|
||||||
|
tags=data.get("tags", ""),
|
||||||
|
)
|
||||||
|
db.add(scenario)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
{
|
||||||
|
"title": "[Demo] The Sunken Crypt of Vharos",
|
||||||
|
"description": "A short dungeon-crawl demo scenario with story cards and one of each script hook, for testing the app end to end.",
|
||||||
|
"prompt": "Rain hammers the moors as you descend the moss-slick steps beneath the ruined chapel. Your torch gutters in the stale air. Below, the Sunken Crypt of Vharos waits — its iron door ajar, as if someone (or something) expected you. Mira's warning rings in your ears: bring back the Ember Amulet before nightfall, or the village of Hollowmere burns.\n\nYou stand before the iron door, water pooling around your boots.",
|
||||||
|
"memory": "The player is an adventurer hired by Mira, blacksmith of Hollowmere, to retrieve the Ember Amulet from the Sunken Crypt of Vharos before nightfall. The crypt is flooded, dark, and haunted. Tone: classic D&D dungeon crawl, dangerous but fair.",
|
||||||
|
"authors_note": "Keep scenes tense and grounded; offer clear choices; consequences matter.",
|
||||||
|
"ai_instructions": "Write in second person, present tense. End each response at a moment where the player can act.",
|
||||||
|
"tags": "demo, dungeon, fantasy, short",
|
||||||
|
"story_cards": [
|
||||||
|
{
|
||||||
|
"type": "character",
|
||||||
|
"name": "Mira the Blacksmith",
|
||||||
|
"keys": "Mira, blacksmith",
|
||||||
|
"entry": "Mira is Hollowmere's blacksmith: broad-shouldered, gray-braided, practical. She hired the player and paid half up front. She knows more about the crypt than she has admitted.",
|
||||||
|
"notes": "Secretly a descendant of Vharos."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "location",
|
||||||
|
"name": "The Sunken Crypt",
|
||||||
|
"keys": "crypt, tomb, Vharos",
|
||||||
|
"entry": "A flooded burial complex beneath a ruined chapel. Knee-deep black water, collapsed pillars, and phosphorescent moss. Three chambers: the Drowned Hall, the Ossuary, and the sealed Reliquary where the Ember Amulet rests.",
|
||||||
|
"notes": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "item",
|
||||||
|
"name": "The Ember Amulet",
|
||||||
|
"keys": "amulet, ember",
|
||||||
|
"entry": "A fist-sized garnet on a bronze chain that glows like a coal. It keeps Hollowmere's protective hearth-ward burning. Touching it bare-handed brands the flesh but does no lasting harm.",
|
||||||
|
"notes": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"scripts": [
|
||||||
|
{
|
||||||
|
"name": "[Demo] Dice Roller",
|
||||||
|
"description": "Input hook + shared library. Type '!roll 2d6' or '!roll d20' in a Do/Say/Story action and the command is replaced with the rolled result.",
|
||||||
|
"library_js": "// Shared library: available to every hook of this script.\nfunction rollDice(count, sides) {\n var total = 0, rolls = [];\n for (var i = 0; i < count; i++) {\n var r = Math.floor(Math.random() * sides) + 1;\n rolls.push(r);\n total += r;\n }\n return { total: total, rolls: rolls };\n}\n",
|
||||||
|
"input_js": "const modifier = (text) => {\n // Replace every \"!roll NdS\" (N optional) with the roll result.\n var out = text.replace(/!roll\\s+(\\d*)d(\\d+)/gi, function (m, n, s) {\n var count = parseInt(n || \"1\", 10);\n var sides = parseInt(s, 10);\n var res = rollDice(count, sides);\n log(\"Rolled \" + count + \"d\" + sides + \": [\" + res.rolls.join(\", \") + \"] = \" + res.total);\n return \"(rolled \" + count + \"d\" + sides + \": \" + res.total + \")\";\n });\n return { text: out };\n};\nmodifier(text);\n",
|
||||||
|
"context_js": "",
|
||||||
|
"output_js": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "[Demo] Turn & HP Tracker",
|
||||||
|
"description": "Demonstrates persistent state. Counts turns; '!hp -3' or '!hp +5' in input adjusts HP (starts at 20). Current stats appear in state.message.",
|
||||||
|
"library_js": "",
|
||||||
|
"input_js": "const modifier = (text) => {\n if (state.hp === undefined) state.hp = 20;\n state.turns = (state.turns || 0) + 1;\n\n var out = text.replace(/!hp\\s*([+-]\\d+)/gi, function (m, delta) {\n state.hp += parseInt(delta, 10);\n return \"\";\n });\n\n state.message = \"Turn \" + state.turns + \" | HP: \" + state.hp + \"/20\";\n log(state.message);\n\n if (state.hp <= 0) {\n // stop:true ends the turn before the AI is called.\n return { text: out + \"\\n\\nYou have fallen. (HP reached 0 — turn stopped by script.)\", stop: true };\n }\n return { text: out };\n};\nmodifier(text);\n",
|
||||||
|
"context_js": "",
|
||||||
|
"output_js": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "[Demo] Context Inspector",
|
||||||
|
"description": "Context hook: logs the size of the assembled context each turn and appends a style directive. Check the logs/context in the action's context snapshot.",
|
||||||
|
"library_js": "",
|
||||||
|
"input_js": "",
|
||||||
|
"context_js": "const modifier = (text) => {\n log(\"Context size: \" + text.length + \" chars, actions so far: \" + info.actionCount\n + \", story cards: \" + storyCards.length);\n // Anything returned here replaces what is sent to the model.\n return { text: text + \"\\n[Style: keep the response under three paragraphs.]\" };\n};\nmodifier(text);\n",
|
||||||
|
"output_js": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "[Demo] Output Polish + Card Discovery",
|
||||||
|
"description": "Output hook: trims a trailing incomplete sentence from the AI reply, and auto-creates a story card the first time the ghost Vharos is mentioned.",
|
||||||
|
"library_js": "",
|
||||||
|
"input_js": "",
|
||||||
|
"context_js": "",
|
||||||
|
"output_js": "const modifier = (text) => {\n var out = text;\n\n // Drop a trailing sentence fragment (no ending punctuation).\n var m = out.match(/^([\\s\\S]*[.!?\"'\\u2026])[^.!?\"'\\u2026]*$/);\n if (m && m[1].length > 40) {\n if (m[1].length < out.length) log(\"Trimmed incomplete final sentence.\");\n out = m[1];\n }\n\n // Demonstrate script-created story cards.\n if (/vharos/i.test(out)) {\n var added = addStoryCard(\n \"Vharos, ghost, spirit\",\n \"Vharos was the crypt's architect, now a restless ghost bound to the amulet he was buried with. He speaks in echoes and cannot lie.\",\n \"character\"\n );\n if (added !== false) log(\"Vharos mentioned — story card created.\");\n }\n\n return { text: out };\n};\nmodifier(text);\n"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"title": "[Demo] Signal from the Derelict",
|
||||||
|
"description": "A tense sci-fi mystery aboard a drifting research station. Showcases the memory and story-card context engine with no scripts required.",
|
||||||
|
"prompt": "The airlock cycles with a hiss, and the cold of the Meridian station folds around your suit. Six days ago it stopped answering hails; three hours ago your ship's antenna caught a single repeating pulse from inside — a heartbeat rhythm, too regular to be an accident. Emergency lighting stutters red down the corridor ahead. Your suit reads breathable air, faint traces of ozone, and no life signs.\n\nYou float at the threshold, the pulse ticking softly in your headset.",
|
||||||
|
"memory": "The player is a salvage contractor who boarded the derelict research station Meridian after it went silent six days ago. A repeating signal is coming from somewhere inside. The crew of nine is missing. Tone: grounded, tense sci-fi mystery — quiet dread over action.",
|
||||||
|
"authors_note": "Keep the atmosphere claustrophobic and quiet; reward careful investigation; make the station feel physical and specific.",
|
||||||
|
"ai_instructions": "Write in second person, present tense. Favor sensory detail and restraint. End each response where the player can act.",
|
||||||
|
"tags": "demo, sci-fi, mystery, short",
|
||||||
|
"story_cards": [
|
||||||
|
{
|
||||||
|
"type": "location",
|
||||||
|
"name": "Meridian Station",
|
||||||
|
"keys": "Meridian, station, ship",
|
||||||
|
"entry": "A mid-sized orbital research station in a decaying orbit around a gas giant. Ring layout: docking spur, crew habitat, the labs, and the sealed reactor core. Power is failing; artificial gravity is intermittent. It smells faintly of ozone and antiseptic.",
|
||||||
|
"notes": "Orbit decays fully in roughly 40 hours — a soft clock."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "character",
|
||||||
|
"name": "Dr. Reyes",
|
||||||
|
"keys": "Reyes, doctor, chief scientist",
|
||||||
|
"entry": "Dr. Ana Reyes was Meridian's chief scientist: meticulous, secretive, protective of her work. Her personal logs are locked. The last public entry, six days ago, reads only: 'It answered back.'",
|
||||||
|
"notes": "Whereabouts unknown. Her lab is sealed from the inside."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "item",
|
||||||
|
"name": "The Signal",
|
||||||
|
"keys": "signal, pulse, heartbeat",
|
||||||
|
"entry": "A repeating low-frequency pulse broadcasting from deep in the station, roughly one beat per second. It is not a standard distress code. The closer you get to the labs, the stronger it becomes.",
|
||||||
|
"notes": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"scripts": []
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user