Move adventure-text field to the front of game setup

The adventure-text field was buried at the bottom of the proposal
form, after the whole interview — the user wanted it to be the very
first thing shown when clicking "Neues Spiel", since pasting an
adventure should drive the proposal rather than follow it.

GameSetupChat now opens on an "Eigenes Abenteuer (optional)" step:
paste the text (or skip) before the wizard chat starts at all. The
setup-chat backend call now receives that text and, when present,
infers genre/world/scope from its opening directly instead of asking
about them — the proposed name/description come out already specific
to the pasted adventure. Removed the now-redundant adventure textarea
from the proposal form (it's collected once, upfront).

Verified live: pasting a short pirate one-shot produced "Klingt nach
einem klassischen Piraten-One-Shot rund um Port Royal – das Genre und
den Umfang leite ich direkt daraus ab", skipping straight to the one
remaining question (experience level), and the final proposal named
Isabella Steel and Blackfin's fortress directly from the text.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Thorsten
2026-09-02 13:35:45 +02:00
parent b77f066259
commit 07665595a2
6 changed files with 58 additions and 24 deletions
+6 -2
View File
@@ -41,10 +41,14 @@ export interface GameSetupChatResponse {
export const listGames = () => apiFetch<Game[]>("/api/games");
export const runGameSetupChat = (messages: Record<string, unknown>[], signal?: AbortSignal) =>
export const runGameSetupChat = (
messages: Record<string, unknown>[],
adventureText?: string,
signal?: AbortSignal,
) =>
apiFetch<GameSetupChatResponse>("/api/games/setup-chat", {
method: "POST",
body: JSON.stringify({ messages }),
body: JSON.stringify({ messages, adventure_text: adventureText ?? "" }),
signal,
});
+34 -18
View File
@@ -10,13 +10,14 @@ interface DisplayTurn {
}
export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) => void }) {
const [step, setStep] = useState<"adventure" | "chat">("adventure");
const [adventureText, setAdventureText] = useState("");
const [rawMessages, setRawMessages] = useState<Record<string, unknown>[]>([]);
const [turns, setTurns] = useState<DisplayTurn[]>([]);
const [proposal, setProposal] = useState<GameSetupProposal | null>(null);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [adventureText, setAdventureText] = useState("");
const [loading, setLoading] = useState(true);
const [loading, setLoading] = useState(false);
const [creating, setCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
const bottomRef = useRef<HTMLDivElement>(null);
@@ -44,11 +45,12 @@ export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) =
}
useEffect(() => {
if (step !== "chat") return;
const controller = new AbortController();
(async () => {
setLoading(true);
try {
const res = await runGameSetupChat([], controller.signal);
const res = await runGameSetupChat([], adventureText, controller.signal);
applyResponse(res);
} catch (err) {
if (!(err instanceof DOMException && err.name === "AbortError")) throw err;
@@ -60,7 +62,7 @@ export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) =
controller.abort();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [step]);
useEffect(() => {
bottomRef.current?.scrollIntoView({ block: "end" });
@@ -69,7 +71,7 @@ export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) =
async function handleSend(content: string) {
setTurns((prev) => [...prev, { role: "user", content }]);
const nextMessages = [...rawMessages, { role: "user", content }];
applyResponse(await runGameSetupChat(nextMessages));
applyResponse(await runGameSetupChat(nextMessages, adventureText));
}
async function handleCreate(e: FormEvent) {
@@ -85,6 +87,33 @@ export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) =
}
}
if (step === "adventure") {
return (
<div className="mb-6 rounded-lg border border-slate-800 bg-slate-900 p-4">
<h2 className="mb-1 text-lg font-semibold text-amber-400">Eigenes Abenteuer (optional)</h2>
<p className="mb-3 text-sm text-slate-400">
Hast du schon ein komplettes, frei verfügbares Abenteuer? Füg hier den vollständigen Text ein —
der Dungeon Master hält sich dann strikt daran (Handlung, NPCs, Orte, Encounter), statt eigene
Inhalte zu erfinden. Name und Beschreibung werden danach automatisch dazu passend vorgeschlagen.
Du kannst diesen Schritt auch überspringen.
</p>
<textarea
placeholder="Vollständiger Text eines frei verfügbaren Abenteuers..."
value={adventureText}
onChange={(e) => setAdventureText(e.target.value)}
className="w-full rounded bg-slate-800 px-3 py-2 font-mono text-xs outline-none focus:ring-2 focus:ring-amber-400"
rows={8}
/>
<button
onClick={() => setStep("chat")}
className="mt-3 rounded bg-amber-500 px-4 py-2 text-sm font-semibold text-slate-950 hover:bg-amber-400"
>
{adventureText.trim() ? "Weiter" : "Ohne Abenteuertext fortfahren"}
</button>
</div>
);
}
return (
<div className="mb-6 rounded-lg border border-slate-800 bg-slate-900">
<div className="flex h-64 flex-col gap-2 overflow-y-auto p-4">
@@ -121,19 +150,6 @@ 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}
/>
<div>
<textarea
placeholder="Abenteuertext (optional) — vollständiger Text eines frei verfügbaren Abenteuers"
value={adventureText}
onChange={(e) => setAdventureText(e.target.value)}
className="w-full rounded bg-slate-800 px-3 py-2 font-mono text-xs outline-none focus:ring-2 focus:ring-amber-400"
rows={6}
/>
<p className="mt-1 text-xs text-slate-500">
Wenn hier etwas steht, hält sich der Dungeon Master strikt an diesen Text (Handlung, NPCs,
Orte, Encounter) statt eigene Inhalte zu erfinden.
</p>
</div>
{error && <p className="text-sm text-red-400">{error}</p>}
<button
type="submit"