From f9b97858570d0e915dd7eb43e1ddbf23b780bbe6 Mon Sep 17 00:00:00 2001 From: Thorsten Date: Mon, 7 Sep 2026 11:58:42 +0200 Subject: [PATCH] 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 --- backend/app/llm/client.py | 5 ++ backend/app/llm/game_setup.py | 54 +++++++++++++++++-- .../llm/prompts/adventure_outline_prompt.txt | 28 ++++++++++ backend/app/schemas/game_setup.py | 1 + frontend/src/api/games.ts | 1 + frontend/src/components/GameSetupChat.tsx | 25 +++++++-- 6 files changed, 108 insertions(+), 6 deletions(-) create mode 100644 backend/app/llm/prompts/adventure_outline_prompt.txt diff --git a/backend/app/llm/client.py b/backend/app/llm/client.py index 28978e6..1f931e4 100644 --- a/backend/app/llm/client.py +++ b/backend/app/llm/client.py @@ -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": diff --git a/backend/app/llm/game_setup.py b/backend/app/llm/game_setup.py index e275b75..8c0a808 100644 --- a/backend/app/llm/game_setup.py +++ b/backend/app/llm/game_setup.py @@ -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": , "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, + } diff --git a/backend/app/llm/prompts/adventure_outline_prompt.txt b/backend/app/llm/prompts/adventure_outline_prompt.txt new file mode 100644 index 0000000..c8e1e73 --- /dev/null +++ b/backend/app/llm/prompts/adventure_outline_prompt.txt @@ -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. diff --git a/backend/app/schemas/game_setup.py b/backend/app/schemas/game_setup.py index 1419f60..552d06d 100644 --- a/backend/app/schemas/game_setup.py +++ b/backend/app/schemas/game_setup.py @@ -15,3 +15,4 @@ class GameSetupChatResponse(BaseModel): messages: list[dict] assistant_text: str | None proposal: GameSetupProposal | None + generated_adventure: str | None = None diff --git a/frontend/src/api/games.ts b/frontend/src/api/games.ts index 37a6247..8d68125 100644 --- a/frontend/src/api/games.ts +++ b/frontend/src/api/games.ts @@ -39,6 +39,7 @@ export interface GameSetupChatResponse { messages: Record[]; assistant_text: string | null; proposal: GameSetupProposal | null; + generated_adventure: string | null; } export const listGames = () => apiFetch("/api/games"); diff --git a/frontend/src/components/GameSetupChat.tsx b/frontend/src/components/GameSetupChat.tsx index 9b9720e..29cc957 100644 --- a/frontend/src/components/GameSetupChat.tsx +++ b/frontend/src/components/GameSetupChat.tsx @@ -15,6 +15,7 @@ export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) = const [rawMessages, setRawMessages] = useState[]>([]); const [turns, setTurns] = useState([]); const [proposal, setProposal] = useState(null); + const [generatedAdventure, setGeneratedAdventure] = useState(null); const [name, setName] = useState(""); const [description, setDescription] = useState(""); const [loading, setLoading] = useState(false); @@ -26,6 +27,7 @@ export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) = messages: Record[]; assistant_text: string | null; proposal: GameSetupProposal | null; + generated_adventure?: string | null; }) { setRawMessages(res.messages); if (res.assistant_text) { @@ -42,6 +44,9 @@ export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) = ]); } } + if (res.generated_adventure) { + setGeneratedAdventure(res.generated_adventure); + } } useEffect(() => { @@ -79,7 +84,7 @@ export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) = setCreating(true); setError(null); try { - const game = await createGame(name, description, adventureText); + const game = await createGame(name, description, adventureText || generatedAdventure || ""); onCreated(game); } catch { setError("Spiel konnte nicht erstellt werden."); @@ -129,7 +134,13 @@ export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) = ))} - {loading &&

Der Dungeon Master überlegt…

} + {loading && ( +

+ {adventureText.trim() + ? "Der Dungeon Master überlegt…" + : "Der Dungeon Master überlegt … und entwirft bei Bedarf ein Abenteuer (kann einen Moment dauern)"} +

+ )}
@@ -150,6 +161,14 @@ export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) = className="rounded bg-slate-800 px-3 py-2 outline-none focus:ring-2 focus:ring-amber-400" rows={3} /> + {!adventureText.trim() && generatedAdventure && ( +
+ + Abenteuer-Entwurf ansehen (wird automatisch verwendet) + +
{generatedAdventure}
+
+ )} {error &&

{error}

}