Auto-generate an adventure outline when no adventure text is provided
When a player creates a game without pasting their own adventure, the DM previously improvised turn-by-turn with no overall arc, ending, or calibrated difficulty. Now the setup-chat interview generates a full outline (hook, three-to-four-part structure, milestone leveling, explicit ending condition, GM guidance) as a follow-up LLM call once genre/length/experience are known, and feeds it through the existing adventure_text -> RAG ingestion -> "follow strictly" pipeline exactly like a pasted adventure. Falls back silently to today's improvised behavior if generation fails. Verified end-to-end in the browser: setup interview -> outline generated and previewed -> game created -> RAG chunks ingested -> has_adventure true -> cascade-cleaned on delete. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,11 @@ def get_game_setup_prompt() -> str:
|
||||
return (PROMPTS_DIR / "game_setup_prompt.txt").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_adventure_outline_prompt() -> str:
|
||||
return (PROMPTS_DIR / "adventure_outline_prompt.txt").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_llm_client() -> AsyncOpenAI:
|
||||
if settings.llm_provider == "lmstudio":
|
||||
|
||||
@@ -1,24 +1,63 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from app.config import settings
|
||||
from app.llm.client import get_game_setup_prompt, get_llm_client
|
||||
from app.llm.client import get_adventure_outline_prompt, get_game_setup_prompt, get_llm_client
|
||||
from app.llm.json_utils import fix_double_escaped_unicode
|
||||
from app.llm.tools import game_setup as game_setup_tool
|
||||
|
||||
logger = logging.getLogger("app.llm.game_setup")
|
||||
|
||||
MAX_ROUNDS = 3
|
||||
MAX_TOKENS = 1024
|
||||
# Only the opening of a pasted adventure is needed here to infer genre/world/scope for the
|
||||
# proposal — this is a cheap interview call, not the RAG pipeline that handles the full text.
|
||||
ADVENTURE_EXCERPT_CHARS = 3000
|
||||
# The outline is a free-text document of several thousand words, unlike the short
|
||||
# name+description proposal above, so it needs a much larger budget.
|
||||
OUTLINE_MAX_TOKENS = 4096
|
||||
|
||||
TOOLS = [{"type": "function", "function": game_setup_tool.TOOL_SCHEMA}]
|
||||
|
||||
|
||||
async def _generate_adventure_outline(client, messages: list[dict], proposal: dict) -> str | None:
|
||||
"""One-shot follow-up call: draft a full adventure outline from the interview so far.
|
||||
|
||||
Only called when the player didn't paste their own adventure text. Best-effort — any
|
||||
failure just means the game falls back to today's fully-improvised DM, never blocks
|
||||
game creation.
|
||||
"""
|
||||
outline_messages = [
|
||||
{"role": "system", "content": get_adventure_outline_prompt()},
|
||||
*messages[1:],
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
f"Vorgeschlagener Spielname: {proposal.get('name', '')}\n"
|
||||
f"Kurzbeschreibung: {proposal.get('description', '')}\n\n"
|
||||
"Entwirf jetzt das vollständige Abenteuer-Outline wie beschrieben."
|
||||
),
|
||||
},
|
||||
]
|
||||
try:
|
||||
response = await client.chat.completions.create(
|
||||
model=settings.dm_model,
|
||||
max_tokens=OUTLINE_MAX_TOKENS,
|
||||
messages=outline_messages,
|
||||
)
|
||||
content = response.choices[0].message.content
|
||||
return content.strip() if content and content.strip() else None
|
||||
except Exception:
|
||||
logger.exception("Adventure outline generation failed")
|
||||
return None
|
||||
|
||||
|
||||
async def run_game_setup_turn(client_messages: list[dict], adventure_text: str = "") -> dict:
|
||||
"""Stateless setup-wizard turn: the caller owns conversation history (no DB, no game yet).
|
||||
|
||||
Returns {"messages": <history to resend next turn>, "assistant_text": str | None,
|
||||
"proposal": {"name": str, "description": str} | None}.
|
||||
"proposal": {"name": str, "description": str} | None,
|
||||
"generated_adventure": str | None}.
|
||||
"""
|
||||
client = get_llm_client()
|
||||
system_prompt = get_game_setup_prompt()
|
||||
@@ -64,4 +103,13 @@ async def run_game_setup_turn(client_messages: list[dict], adventure_text: str =
|
||||
if proposal:
|
||||
break
|
||||
|
||||
return {"messages": messages[1:], "assistant_text": assistant_text, "proposal": proposal}
|
||||
generated_adventure: str | None = None
|
||||
if proposal and not adventure_text.strip():
|
||||
generated_adventure = await _generate_adventure_outline(client, messages, proposal)
|
||||
|
||||
return {
|
||||
"messages": messages[1:],
|
||||
"assistant_text": assistant_text,
|
||||
"proposal": proposal,
|
||||
"generated_adventure": generated_adventure,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
Rolle
|
||||
Du bist ein erfahrener Dungeon Master und entwirfst gerade das Grundgerüst für ein neues D&D-Abenteuer, bevor das eigentliche Spiel beginnt. Nutze das, was im bisherigen Setup-Gespräch (Genre/Welt, Umfang, Erfahrungsstand der Spieler) sowie im vorgeschlagenen Namen und der Kurzbeschreibung erkennbar ist, um ein passendes, in sich stimmiges Abenteuer zu entwerfen.
|
||||
|
||||
Zielgruppe
|
||||
4 bis 6 Spielercharaktere, Stufe 1 zu Beginn, gedacht für einen One-Shot (ca. 3-5 Sitzungen à mehrere Stunden). Am Ende sollten die Charaktere durch das Bestehen der wichtigsten Stationen auf Stufe 2-3 aufgestiegen sein.
|
||||
|
||||
Aufbau
|
||||
Schreibe das Abenteuer als zusammenhängenden Fließtext (kein JSON, gerne mit Markdown-Überschriften) mit folgenden Abschnitten:
|
||||
|
||||
1. Titel und Prämisse: ein einprägsamer Titel, ein kurzer Absatz, worum es geht.
|
||||
2. Hintergrund: wer oder was ist die eigentliche Bedrohung, welche Motivation steckt dahinter (in-universe erzählt).
|
||||
3. Zusammenfassung: der grobe Handlungsbogen von Anfang bis Ende sowie die eindeutige Siegbedingung — wann gilt die Bedrohung als besiegt.
|
||||
4. Einstieg: ein bis zwei plausible Gründe (Hooks), warum die Charaktere gemeinsam losziehen.
|
||||
5. Drei bis vier Teile, die zusammen Anfang, Mitte und Ende des Abenteuers bilden. Jeder Teil bekommt einen Titel und enthält:
|
||||
- eine kurze Ortsbeschreibung
|
||||
- zwei bis vier nummerierte Orte oder Encounter mit knappem Vorlese-Text-Ansatz
|
||||
- benannte NPCs oder Monster mit kurzer Motivation/Verhalten
|
||||
- wo sinnvoll: ein bis zwei Fertigkeitswürfe mit Schwierigkeitsgrad (SG)
|
||||
- Hinweise auf Beute/Belohnungen, wo passend
|
||||
6. Meilenstein-Aufstieg: an welchen konkreten Punkten der Handlung die Gruppe eine Stufe aufsteigt (Meilenstein-System, kein XP-Tracking).
|
||||
7. Ende der Bedrohung: die explizite Bedingung, unter der das Abenteuer als erfolgreich abgeschlossen gilt.
|
||||
8. Hinweise für die Spielleitung: was tun, wenn die Spieler klar von der vorgesehenen Route abweichen; wie ein besonders schwerer Showdown-Kampf für eine Stufe-1-Gruppe fair entschärft werden kann.
|
||||
|
||||
Umfang
|
||||
Halte den Text auf einige tausend Wörter begrenzt — es soll ein kompaktes, spielbares Gerüst sein, keine ausführliche, seitenlange Kampagne. Monster-Werte müssen nicht im Detail ausformuliert werden (die Spielleitung hat Zugriff auf die vollständigen Regelwerke); es reicht, bekannte D&D-Monster oder -Archetypen beim Namen zu nennen.
|
||||
|
||||
Sprache
|
||||
Schreibe auf Deutsch, unabhängig davon, in welcher Sprache das bisherige Setup-Gespräch geführt wurde.
|
||||
@@ -15,3 +15,4 @@ class GameSetupChatResponse(BaseModel):
|
||||
messages: list[dict]
|
||||
assistant_text: str | None
|
||||
proposal: GameSetupProposal | None
|
||||
generated_adventure: str | None = None
|
||||
|
||||
Reference in New Issue
Block a user