Compare commits

..

2 Commits

Author SHA1 Message Date
Thorsten 7c374f2bc2 Add server migration guide
Documents the backup/restore-based steps for moving the app to a new
machine with Docker already installed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 12:18:57 +02:00
Thorsten f9b9785857 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>
2026-09-07 11:58:42 +02:00
7 changed files with 162 additions and 6 deletions
+5
View File
@@ -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":
+51 -3
View File
@@ -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.
+1
View File
@@ -15,3 +15,4 @@ class GameSetupChatResponse(BaseModel):
messages: list[dict]
assistant_text: str | None
proposal: GameSetupProposal | None
generated_adventure: str | None = None
+54
View File
@@ -0,0 +1,54 @@
# Umzug auf einen anderen Rechner
Anleitung, um den aktuellen Stand (inkl. Datenbank) auf einem anderen Rechner mit bereits laufendem Docker neu aufzusetzen.
## Auf diesem Rechner (Vorbereitung)
Ein aktuelles Backup erstellen:
```bash
bash scripts/backup-db.sh
```
Zwei Dateien musst du selbst rüberkopieren (USB-Stick, Netzwerkfreigabe, scp — was im LAN am einfachsten ist):
- `.env` (Projekt-Root)
- die soeben erstellte `backups/dnd_backup_<timestamp>.dump`
## Auf dem neuen Rechner
```bash
git clone http://172.17.2.68:3001/nova/DungeonsDragons.git
cd DungeonsDragons
```
Dann `.env` in den Repo-Root legen, und die `.dump`-Datei z. B. in einen `backups/`-Unterordner.
### Wichtig — einen Wert in der übertragenen `.env` anpassen
```
FRONTEND_ORIGIN=http://localhost:5173
```
Das ist der Dev-Wert vom ursprünglichen Rechner. Auf dem neuen sollte es die tatsächliche Adresse sein, unter der das Frontend dort erreichbar ist (z. B. `http://<neue-IP>:8080`) — wird für CORS und Passwort-Reset-Links gebraucht.
Alles andere (DB-Zugangsdaten, x.ai-Key, JWT-Secrets) kann unverändert bleiben — gleiche Secrets heißt bestehende Logins bleiben gültig.
### Datenbank zuerst separat hochziehen und befüllen, bevor der Rest startet
```bash
docker compose up -d postgres
# kurz warten, bis er "healthy" ist (docker compose ps)
bash scripts/restore-db.sh backups/dnd_backup_<timestamp>.dump -y
docker compose up -d --build
```
Der Restore bringt Schema und Daten in einem Rutsch mit (kein separates `alembic upgrade` nötig — der Dump enthält den kompletten aktuellen Migrationsstand). Die drei Regelwerk-Textdateien (`backend/rulebooks/`) sind bereits im Git-Repo enthalten, brauchen also keinen Extra-Transfer.
### Check danach
```bash
curl http://localhost:8000/health
```
und Frontend im Browser unter `http://<neuer-Rechner>:8080` aufrufen.
+1
View File
@@ -39,6 +39,7 @@ export interface GameSetupChatResponse {
messages: Record<string, unknown>[];
assistant_text: string | null;
proposal: GameSetupProposal | null;
generated_adventure: string | null;
}
export const listGames = () => apiFetch<Game[]>("/api/games");
+22 -3
View File
@@ -15,6 +15,7 @@ export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) =
const [rawMessages, setRawMessages] = useState<Record<string, unknown>[]>([]);
const [turns, setTurns] = useState<DisplayTurn[]>([]);
const [proposal, setProposal] = useState<GameSetupProposal | null>(null);
const [generatedAdventure, setGeneratedAdventure] = useState<string | null>(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<string, unknown>[];
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) =
<MarkdownContent content={turn.content} />
</div>
))}
{loading && <p className="text-sm text-slate-500">Der Dungeon Master überlegt…</p>}
{loading && (
<p className="text-sm text-slate-500">
{adventureText.trim()
? "Der Dungeon Master überlegt…"
: "Der Dungeon Master überlegt … und entwirft bei Bedarf ein Abenteuer (kann einen Moment dauern)"}
</p>
)}
<div ref={bottomRef} />
</div>
<SendMessageForm onSend={handleSend} disabled={loading} placeholder="Deine Antwort..." />
@@ -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 && (
<details className="rounded border border-slate-800 bg-slate-950 p-3 text-sm text-slate-300">
<summary className="cursor-pointer font-semibold text-amber-400">
Abenteuer-Entwurf ansehen (wird automatisch verwendet)
</summary>
<div className="mt-2 max-h-64 overflow-y-auto whitespace-pre-wrap">{generatedAdventure}</div>
</details>
)}
{error && <p className="text-sm text-red-400">{error}</p>}
<button
type="submit"
@@ -157,7 +176,7 @@ export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) =
className="self-start rounded bg-amber-500 px-4 py-2 text-sm font-semibold text-slate-950 hover:bg-amber-400 disabled:opacity-50"
>
{creating
? adventureText.trim()
? adventureText.trim() || generatedAdventure
? "Erstelle… (Abenteuertext wird verarbeitet, kann etwas dauern)"
: "Erstelle…"
: "Erstellen"}