diff --git a/backend/app/api/routes_games.py b/backend/app/api/routes_games.py index 9b2d727..42dd132 100644 --- a/backend/app/api/routes_games.py +++ b/backend/app/api/routes_games.py @@ -123,7 +123,7 @@ async def game_setup_chat( payload: GameSetupChatRequest, user: User = Depends(current_active_user), ) -> GameSetupChatResponse: - result = await run_game_setup_turn(payload.messages) + result = await run_game_setup_turn(payload.messages, payload.adventure_text) return GameSetupChatResponse(**result) diff --git a/backend/app/llm/game_setup.py b/backend/app/llm/game_setup.py index ccb711c..e275b75 100644 --- a/backend/app/llm/game_setup.py +++ b/backend/app/llm/game_setup.py @@ -7,18 +7,29 @@ from app.llm.tools import game_setup as game_setup_tool 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 TOOLS = [{"type": "function", "function": game_setup_tool.TOOL_SCHEMA}] -async def run_game_setup_turn(client_messages: list[dict]) -> dict: +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}. """ client = get_llm_client() - messages = [{"role": "system", "content": get_game_setup_prompt()}] + client_messages + system_prompt = get_game_setup_prompt() + if adventure_text.strip(): + excerpt = adventure_text.strip()[:ADVENTURE_EXCERPT_CHARS] + system_prompt = ( + f"{system_prompt}\n\nDer Spieler hat bereits ein vorhandenes Abenteuer mitgegeben " + f"(Anfang unten). Leite Genre/Welt daraus ab, statt danach zu fragen — frage nur noch, " + f"was daraus nicht hervorgeht:\n\n{excerpt}" + ) + messages = [{"role": "system", "content": system_prompt}] + client_messages assistant_text: str | None = None proposal: dict | None = None diff --git a/backend/app/llm/prompts/game_setup_prompt.txt b/backend/app/llm/prompts/game_setup_prompt.txt index 14bb81c..366f297 100644 --- a/backend/app/llm/prompts/game_setup_prompt.txt +++ b/backend/app/llm/prompts/game_setup_prompt.txt @@ -8,7 +8,9 @@ Stelle dem Spieler nacheinander (eine Frage nach der anderen, nicht alle auf ein 2. Wie lang soll das Spiel werden — ein einzelnes One-Shot-Abenteuer oder eine längere Kampagne über mehrere Sitzungen? 3. Wie ist sein Erfahrungsstand mit D&D — komplette(r) Anfänger(in), gelegentlich gespielt, oder erfahrene(r) Regelkenner(in)? -Sobald du alle drei Antworten hast, rufe das Werkzeug propose_game_setup auf mit: +Wurde dir bereits ein vorhandenes Abenteuer mitgegeben (siehe unten), leite Genre/Welt und ggf. den Umfang direkt daraus ab und frage dazu nicht extra nach — stelle nur noch die Fragen, deren Antwort sich nicht aus dem Text ergibt. + +Sobald du alle drei Antworten hast (oder aus dem mitgegebenen Abenteuer kennst), rufe das Werkzeug propose_game_setup auf mit: - einem passenden, einprägsamen Namen für das Spiel - einer kurzen Beschreibung (Genre und Welt, optional ein Hinweis auf Umfang und Erfahrungsstand) diff --git a/backend/app/schemas/game_setup.py b/backend/app/schemas/game_setup.py index b1598c3..1419f60 100644 --- a/backend/app/schemas/game_setup.py +++ b/backend/app/schemas/game_setup.py @@ -3,6 +3,7 @@ from pydantic import BaseModel class GameSetupChatRequest(BaseModel): messages: list[dict] = [] + adventure_text: str = "" class GameSetupProposal(BaseModel): diff --git a/frontend/src/api/games.ts b/frontend/src/api/games.ts index 7164593..c6044ff 100644 --- a/frontend/src/api/games.ts +++ b/frontend/src/api/games.ts @@ -41,10 +41,14 @@ export interface GameSetupChatResponse { export const listGames = () => apiFetch("/api/games"); -export const runGameSetupChat = (messages: Record[], signal?: AbortSignal) => +export const runGameSetupChat = ( + messages: Record[], + adventureText?: string, + signal?: AbortSignal, +) => apiFetch("/api/games/setup-chat", { method: "POST", - body: JSON.stringify({ messages }), + body: JSON.stringify({ messages, adventure_text: adventureText ?? "" }), signal, }); diff --git a/frontend/src/components/GameSetupChat.tsx b/frontend/src/components/GameSetupChat.tsx index da56035..9b9720e 100644 --- a/frontend/src/components/GameSetupChat.tsx +++ b/frontend/src/components/GameSetupChat.tsx @@ -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[]>([]); const [turns, setTurns] = useState([]); const [proposal, setProposal] = useState(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(null); const bottomRef = useRef(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 ( +
+

Eigenes Abenteuer (optional)

+

+ 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. +

+