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:
@@ -123,7 +123,7 @@ async def game_setup_chat(
|
|||||||
payload: GameSetupChatRequest,
|
payload: GameSetupChatRequest,
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
) -> GameSetupChatResponse:
|
) -> GameSetupChatResponse:
|
||||||
result = await run_game_setup_turn(payload.messages)
|
result = await run_game_setup_turn(payload.messages, payload.adventure_text)
|
||||||
return GameSetupChatResponse(**result)
|
return GameSetupChatResponse(**result)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,18 +7,29 @@ from app.llm.tools import game_setup as game_setup_tool
|
|||||||
|
|
||||||
MAX_ROUNDS = 3
|
MAX_ROUNDS = 3
|
||||||
MAX_TOKENS = 1024
|
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}]
|
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).
|
"""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,
|
Returns {"messages": <history to resend next turn>, "assistant_text": str | None,
|
||||||
"proposal": {"name": str, "description": str} | None}.
|
"proposal": {"name": str, "description": str} | None}.
|
||||||
"""
|
"""
|
||||||
client = get_llm_client()
|
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
|
assistant_text: str | None = None
|
||||||
proposal: dict | None = None
|
proposal: dict | None = None
|
||||||
|
|||||||
@@ -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?
|
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)?
|
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
|
- einem passenden, einprägsamen Namen für das Spiel
|
||||||
- einer kurzen Beschreibung (Genre und Welt, optional ein Hinweis auf Umfang und Erfahrungsstand)
|
- einer kurzen Beschreibung (Genre und Welt, optional ein Hinweis auf Umfang und Erfahrungsstand)
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
class GameSetupChatRequest(BaseModel):
|
class GameSetupChatRequest(BaseModel):
|
||||||
messages: list[dict] = []
|
messages: list[dict] = []
|
||||||
|
adventure_text: str = ""
|
||||||
|
|
||||||
|
|
||||||
class GameSetupProposal(BaseModel):
|
class GameSetupProposal(BaseModel):
|
||||||
|
|||||||
@@ -41,10 +41,14 @@ export interface GameSetupChatResponse {
|
|||||||
|
|
||||||
export const listGames = () => apiFetch<Game[]>("/api/games");
|
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", {
|
apiFetch<GameSetupChatResponse>("/api/games/setup-chat", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ messages }),
|
body: JSON.stringify({ messages, adventure_text: adventureText ?? "" }),
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,13 +10,14 @@ interface DisplayTurn {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) => void }) {
|
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 [rawMessages, setRawMessages] = useState<Record<string, unknown>[]>([]);
|
||||||
const [turns, setTurns] = useState<DisplayTurn[]>([]);
|
const [turns, setTurns] = useState<DisplayTurn[]>([]);
|
||||||
const [proposal, setProposal] = useState<GameSetupProposal | null>(null);
|
const [proposal, setProposal] = useState<GameSetupProposal | null>(null);
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [adventureText, setAdventureText] = useState("");
|
const [loading, setLoading] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -44,11 +45,12 @@ export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) =
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (step !== "chat") return;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
(async () => {
|
(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await runGameSetupChat([], controller.signal);
|
const res = await runGameSetupChat([], adventureText, controller.signal);
|
||||||
applyResponse(res);
|
applyResponse(res);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!(err instanceof DOMException && err.name === "AbortError")) throw err;
|
if (!(err instanceof DOMException && err.name === "AbortError")) throw err;
|
||||||
@@ -60,7 +62,7 @@ export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) =
|
|||||||
controller.abort();
|
controller.abort();
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, [step]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
bottomRef.current?.scrollIntoView({ block: "end" });
|
bottomRef.current?.scrollIntoView({ block: "end" });
|
||||||
@@ -69,7 +71,7 @@ export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) =
|
|||||||
async function handleSend(content: string) {
|
async function handleSend(content: string) {
|
||||||
setTurns((prev) => [...prev, { role: "user", content }]);
|
setTurns((prev) => [...prev, { role: "user", content }]);
|
||||||
const nextMessages = [...rawMessages, { role: "user", content }];
|
const nextMessages = [...rawMessages, { role: "user", content }];
|
||||||
applyResponse(await runGameSetupChat(nextMessages));
|
applyResponse(await runGameSetupChat(nextMessages, adventureText));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCreate(e: FormEvent) {
|
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 (
|
return (
|
||||||
<div className="mb-6 rounded-lg border border-slate-800 bg-slate-900">
|
<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">
|
<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"
|
className="rounded bg-slate-800 px-3 py-2 outline-none focus:ring-2 focus:ring-amber-400"
|
||||||
rows={3}
|
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>}
|
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
Reference in New Issue
Block a user