Initial commit: AI Dungeon clone (FastAPI backend + React frontend)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KFsGHju9szibJJa2YJcdbg
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
"""Seed a demo scenario + adventure with sample scripts for testing.
|
||||
|
||||
Run from the backend folder: .venv\\Scripts\\python.exe seed_demo.py
|
||||
Safe to rerun: it deletes any previous rows titled "[Demo] ..." first.
|
||||
"""
|
||||
|
||||
from app import models, migrations
|
||||
from app.database import SessionLocal, engine
|
||||
|
||||
# create_all + user_version stamp; plain create_all would leave a fresh DB at
|
||||
# version 0 and the server would replay every ALTER TABLE migration on start.
|
||||
migrations.bootstrap(engine)
|
||||
|
||||
DEMO_PREFIX = "[Demo]"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sample scripts — AI Dungeon contract: define modifier(text), call it last.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DICE_ROLLER = dict(
|
||||
name=f"{DEMO_PREFIX} 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.
|
||||
function rollDice(count, sides) {
|
||||
var total = 0, rolls = [];
|
||||
for (var i = 0; i < count; i++) {
|
||||
var r = Math.floor(Math.random() * sides) + 1;
|
||||
rolls.push(r);
|
||||
total += r;
|
||||
}
|
||||
return { total: total, rolls: rolls };
|
||||
}
|
||||
""",
|
||||
input_js="""\
|
||||
const modifier = (text) => {
|
||||
// Replace every "!roll NdS" (N optional) with the roll result.
|
||||
var out = text.replace(/!roll\\s+(\\d*)d(\\d+)/gi, function (m, n, s) {
|
||||
var count = parseInt(n || "1", 10);
|
||||
var sides = parseInt(s, 10);
|
||||
var res = rollDice(count, sides);
|
||||
log("Rolled " + count + "d" + sides + ": [" + res.rolls.join(", ") + "] = " + res.total);
|
||||
return "(rolled " + count + "d" + sides + ": " + res.total + ")";
|
||||
});
|
||||
return { text: out };
|
||||
};
|
||||
modifier(text);
|
||||
""",
|
||||
)
|
||||
|
||||
TURN_TRACKER = dict(
|
||||
name=f"{DEMO_PREFIX} 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."
|
||||
),
|
||||
input_js="""\
|
||||
const modifier = (text) => {
|
||||
if (state.hp === undefined) state.hp = 20;
|
||||
state.turns = (state.turns || 0) + 1;
|
||||
|
||||
var out = text.replace(/!hp\\s*([+-]\\d+)/gi, function (m, delta) {
|
||||
state.hp += parseInt(delta, 10);
|
||||
return "";
|
||||
});
|
||||
|
||||
state.message = "Turn " + state.turns + " | HP: " + state.hp + "/20";
|
||||
log(state.message);
|
||||
|
||||
if (state.hp <= 0) {
|
||||
// stop:true ends the turn before the AI is called.
|
||||
return { text: out + "\\n\\nYou have fallen. (HP reached 0 — turn stopped by script.)", stop: true };
|
||||
}
|
||||
return { text: out };
|
||||
};
|
||||
modifier(text);
|
||||
""",
|
||||
)
|
||||
|
||||
CONTEXT_INSPECTOR = dict(
|
||||
name=f"{DEMO_PREFIX} 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."
|
||||
),
|
||||
context_js="""\
|
||||
const modifier = (text) => {
|
||||
log("Context size: " + text.length + " chars, actions so far: " + info.actionCount
|
||||
+ ", story cards: " + storyCards.length);
|
||||
// Anything returned here replaces what is sent to the model.
|
||||
return { text: text + "\\n[Style: keep the response under three paragraphs.]" };
|
||||
};
|
||||
modifier(text);
|
||||
""",
|
||||
)
|
||||
|
||||
OUTPUT_POLISH = dict(
|
||||
name=f"{DEMO_PREFIX} 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."
|
||||
),
|
||||
output_js="""\
|
||||
const modifier = (text) => {
|
||||
var out = text;
|
||||
|
||||
// Drop a trailing sentence fragment (no ending punctuation).
|
||||
var m = out.match(/^([\\s\\S]*[.!?"'\\u2026])[^.!?"'\\u2026]*$/);
|
||||
if (m && m[1].length > 40) {
|
||||
if (m[1].length < out.length) log("Trimmed incomplete final sentence.");
|
||||
out = m[1];
|
||||
}
|
||||
|
||||
// Demonstrate script-created story cards.
|
||||
if (/vharos/i.test(out)) {
|
||||
var added = addStoryCard(
|
||||
"Vharos, ghost, spirit",
|
||||
"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.",
|
||||
"character"
|
||||
);
|
||||
if (added !== false) log("Vharos mentioned — story card created.");
|
||||
}
|
||||
|
||||
return { text: out };
|
||||
};
|
||||
modifier(text);
|
||||
""",
|
||||
)
|
||||
|
||||
SCRIPTS = [DICE_ROLLER, TURN_TRACKER, CONTEXT_INSPECTOR, OUTPUT_POLISH]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scenario content
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SCENARIO = dict(
|
||||
title=f"{DEMO_PREFIX} 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\n"
|
||||
"You 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 = [
|
||||
dict(
|
||||
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.",
|
||||
),
|
||||
dict(
|
||||
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="",
|
||||
),
|
||||
dict(
|
||||
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="",
|
||||
),
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Remove earlier demo rows so reruns stay clean.
|
||||
for adv in db.query(models.Adventure).filter(models.Adventure.title.like(f"{DEMO_PREFIX}%")):
|
||||
db.delete(adv)
|
||||
for sc in db.query(models.Scenario).filter(models.Scenario.title.like(f"{DEMO_PREFIX}%")):
|
||||
db.delete(sc)
|
||||
for s in db.query(models.Script).filter(models.Script.name.like(f"{DEMO_PREFIX}%")):
|
||||
db.delete(s)
|
||||
db.commit()
|
||||
|
||||
# Script library
|
||||
scripts = [models.Script(**s) for s in SCRIPTS]
|
||||
db.add_all(scripts)
|
||||
|
||||
# Scenario with cards and scripts attached
|
||||
scenario = models.Scenario(**SCENARIO)
|
||||
scenario.scripts = scripts
|
||||
db.add(scenario)
|
||||
db.flush()
|
||||
for card in STORY_CARDS:
|
||||
db.add(models.StoryCard(scenario_id=scenario.id, **card))
|
||||
|
||||
# Adventure created from the scenario, mirroring POST /api/adventures
|
||||
adventure = models.Adventure(
|
||||
scenario_id=scenario.id,
|
||||
title=scenario.title,
|
||||
memory=scenario.memory,
|
||||
authors_note=scenario.authors_note,
|
||||
ai_instructions=scenario.ai_instructions,
|
||||
)
|
||||
db.add(adventure)
|
||||
db.flush()
|
||||
for card in STORY_CARDS:
|
||||
db.add(models.StoryCard(adventure_id=adventure.id, **card))
|
||||
for position, s in enumerate(SCRIPTS):
|
||||
db.add(models.AdventureScript(adventure_id=adventure.id, position=position, **s))
|
||||
db.add(models.Action(adventure_id=adventure.id, index=0, type="start", text=scenario.prompt))
|
||||
db.commit()
|
||||
|
||||
print(f"Scenario id={scenario.id}: {scenario.title}")
|
||||
print(f"Adventure id={adventure.id}: {adventure.title}")
|
||||
print(f"Scripts: {', '.join(s.name for s in scripts)}")
|
||||
finally:
|
||||
db.close()
|
||||
Reference in New Issue
Block a user