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:
Thorsten
2026-09-07 11:58:42 +02:00
parent 1b64010280
commit f9b9785857
6 changed files with 108 additions and 6 deletions
+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"}