Add Phase 1 MVP: D&D text-adventure server

FastAPI + Postgres backend with fastapi-users auth, games/characters
CRUD, and a WebSocket chat endpoint where an x.ai Grok DM narrates
play, rolls dice, and maintains character sheets via tool calls.
React + Tailwind frontend covers registration/login, game list and
creation, participation-code join flow, character viewer, and a
real-time chat session view. Docker Compose skeleton (Postgres +
backend) included; Caddy/frontend container wiring is deferred to
the deploy milestone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Thorsten
2026-08-31 18:43:08 +02:00
commit f37dc9fa76
75 changed files with 106988 additions and 0 deletions
+56
View File
@@ -0,0 +1,56 @@
import { useEffect, useState } from "react";
import { Link, useParams, useSearchParams } from "react-router-dom";
import { Character, getCharacter } from "../api/characters";
export default function CharacterDetail() {
const { characterId } = useParams<{ characterId: string }>();
const [searchParams] = useSearchParams();
const backGameId = searchParams.get("back");
const [character, setCharacter] = useState<Character | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!characterId) return;
getCharacter(characterId)
.then(setCharacter)
.finally(() => setLoading(false));
}, [characterId]);
if (loading) return <p className="p-8 text-slate-400">Lädt…</p>;
if (!character) return <p className="p-8 text-slate-400">Charakter nicht gefunden.</p>;
return (
<div className="mx-auto max-w-2xl px-4 py-8">
{backGameId && (
<Link
to={`/games/${backGameId}`}
className="mb-4 inline-block rounded bg-slate-800 px-3 py-2 text-sm hover:bg-slate-700"
>
← Zurück zum Spiel
</Link>
)}
<h1 className="text-2xl font-bold text-amber-400">{character.name}</h1>
<p className="mt-1 text-slate-400">
{character.race ?? "?"} · {character.char_class ?? "?"} · Stufe {character.level}
</p>
{Object.keys(character.stats).length > 0 && (
<div className="mt-4 grid grid-cols-3 gap-3 sm:grid-cols-6">
{Object.entries(character.stats).map(([stat, value]) => (
<div key={stat} className="rounded bg-slate-800 p-2 text-center">
<div className="text-xs uppercase text-slate-500">{stat}</div>
<div className="text-lg font-semibold text-slate-100">{value}</div>
</div>
))}
</div>
)}
<div className="mt-6 whitespace-pre-wrap rounded-lg border border-slate-800 bg-slate-900 p-4 text-slate-300">
{character.description || "Noch keine Beschreibung."}
</div>
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
import { useEffect, useState } from "react";
import { Character, listCharacters } from "../api/characters";
import CharacterCard from "../components/CharacterCard";
export default function CharactersList() {
const [characters, setCharacters] = useState<Character[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
listCharacters()
.then(setCharacters)
.finally(() => setLoading(false));
}, []);
return (
<div className="mx-auto max-w-3xl px-4 py-8">
<h1 className="mb-6 text-2xl font-bold text-amber-400">Charaktere</h1>
{loading ? (
<p className="text-slate-400">Lädt…</p>
) : characters.length === 0 ? (
<p className="text-slate-400">
Noch keine Charaktere — sie entstehen im Laufe eines Spiels.
</p>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{characters.map((c) => (
<CharacterCard key={c.id} character={c} />
))}
</div>
)}
</div>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { FormEvent, useState } from "react";
import { Link } from "react-router-dom";
import { forgotPassword } from "../api/auth";
export default function ForgotPassword() {
const [email, setEmail] = useState("");
const [sent, setSent] = useState(false);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
await forgotPassword(email).catch(() => undefined);
setSent(true);
}
return (
<div className="mx-auto mt-16 max-w-sm px-4">
<h1 className="mb-6 text-2xl font-bold text-amber-400">Passwort vergessen</h1>
{sent ? (
<p className="text-slate-300">
Falls ein Konto mit dieser E-Mail existiert, wurde eine Nachricht mit einem
Reset-Link verschickt.
</p>
) : (
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<input
type="email"
required
placeholder="E-Mail"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="rounded bg-slate-800 px-3 py-2 outline-none focus:ring-2 focus:ring-amber-400"
/>
<button
type="submit"
className="rounded bg-amber-500 px-3 py-2 font-semibold text-slate-950 hover:bg-amber-400"
>
Link anfordern
</button>
</form>
)}
<div className="mt-4 text-sm text-slate-400">
<Link to="/login" className="hover:text-amber-400">
Zurück zur Anmeldung
</Link>
</div>
</div>
);
}
+181
View File
@@ -0,0 +1,181 @@
import { FormEvent, useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { listCharacters, Character } from "../api/characters";
import { Game, getFullMessages, getGame, getRecentMessages, joinGame, Message } from "../api/games";
import SendMessageForm from "../components/SendMessageForm";
import TranscriptPane from "../components/TranscriptPane";
import { useGameSocket } from "../hooks/useGameSocket";
export default function GameSession() {
const { gameId } = useParams<{ gameId: string }>();
const [game, setGame] = useState<Game | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [showingFull, setShowingFull] = useState(false);
const [loading, setLoading] = useState(true);
const [characters, setCharacters] = useState<Character[]>([]);
const [joinCode, setJoinCode] = useState("");
const [selectedCharacterId, setSelectedCharacterId] = useState("");
const [joinError, setJoinError] = useState<string | null>(null);
async function loadGame() {
if (!gameId) return;
const g = await getGame(gameId);
setGame(g);
return g;
}
async function loadRecent() {
if (!gameId) return;
setMessages(await getRecentMessages(gameId, 5));
setShowingFull(false);
}
async function loadFull() {
if (!gameId) return;
setMessages(await getFullMessages(gameId));
setShowingFull(true);
}
useEffect(() => {
(async () => {
setLoading(true);
const g = await loadGame();
if (g?.is_participant) {
await loadRecent();
} else {
setCharacters(await listCharacters());
}
setLoading(false);
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [gameId]);
async function handleJoin(e: FormEvent) {
e.preventDefault();
if (!gameId) return;
setJoinError(null);
try {
const g = await joinGame(gameId, joinCode, selectedCharacterId || undefined);
setGame(g);
await loadRecent();
} catch {
setJoinError("Beitritt fehlgeschlagen — Teilnahmecode prüfen.");
}
}
const { send } = useGameSocket(
game?.is_participant ? gameId : undefined,
(incoming) => {
setMessages((prev) => {
const next = [...prev, incoming];
return showingFull ? next : next.slice(-5);
});
},
(detail) => {
const text =
detail === "connection_lost"
? "Verbindung zum Server verloren. Bitte lade die Seite neu."
: "Der Dungeon Master antwortet gerade nicht. Bitte versuche es erneut.";
const errorMessage: Message = {
id: -Date.now(),
sender_type: "system",
user_id: null,
player_name: null,
character_id: null,
character_name: null,
content: text,
created_at: new Date().toISOString(),
};
setMessages((prev) => {
const next = [...prev, errorMessage];
return showingFull ? next : next.slice(-5);
});
},
);
async function handleSend(content: string) {
send(content);
}
if (loading) {
return <p className="p-8 text-slate-400">Lädt…</p>;
}
if (!game) {
return <p className="p-8 text-slate-400">Spiel nicht gefunden.</p>;
}
if (!game.is_participant) {
return (
<div className="mx-auto mt-16 max-w-sm px-4">
<h1 className="mb-2 text-2xl font-bold text-amber-400">{game.name}</h1>
<p className="mb-6 text-sm text-slate-400">{game.description}</p>
<form onSubmit={handleJoin} className="flex flex-col gap-4">
<input
required
placeholder="Teilnahmecode"
value={joinCode}
onChange={(e) => setJoinCode(e.target.value.toUpperCase())}
className="rounded bg-slate-800 px-3 py-2 font-mono outline-none focus:ring-2 focus:ring-amber-400"
/>
{characters.length > 0 && (
<select
value={selectedCharacterId}
onChange={(e) => setSelectedCharacterId(e.target.value)}
className="rounded bg-slate-800 px-3 py-2 outline-none focus:ring-2 focus:ring-amber-400"
>
<option value="">Neuen Charakter im Spiel erstellen</option>
{characters.map((c) => (
<option key={c.id} value={c.id}>
{c.name} ({c.race} {c.char_class})
</option>
))}
</select>
)}
{joinError && <p className="text-sm text-red-400">{joinError}</p>}
<button
type="submit"
className="rounded bg-amber-500 px-3 py-2 font-semibold text-slate-950 hover:bg-amber-400"
>
Beitreten
</button>
</form>
</div>
);
}
return (
<div className="mx-auto flex h-[calc(100vh-56px)] max-w-3xl flex-col px-4 py-4">
<div className="mb-3 flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-amber-400">{game.name}</h1>
<p className="text-xs text-slate-500">{game.description}</p>
</div>
<div className="flex gap-2">
{game.my_character_id && (
<Link
to={`/characters/${game.my_character_id}?back=${gameId}`}
className="rounded bg-slate-800 px-3 py-2 text-sm hover:bg-slate-700"
>
Mein Charakter
</Link>
)}
<button
onClick={showingFull ? loadRecent : loadFull}
className="rounded bg-slate-800 px-3 py-2 text-sm hover:bg-slate-700"
>
{showingFull ? "Nur letzte 5" : "Volltext laden"}
</button>
</div>
</div>
<div className="flex flex-1 flex-col overflow-hidden rounded-lg border border-slate-800 bg-slate-900">
<TranscriptPane messages={messages} />
<SendMessageForm onSend={handleSend} />
</div>
</div>
);
}
+95
View File
@@ -0,0 +1,95 @@
import { FormEvent, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { createGame, Game, listGames } from "../api/games";
import GameCard from "../components/GameCard";
export default function GamesList() {
const navigate = useNavigate();
const [games, setGames] = useState<Game[]>([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [error, setError] = useState<string | null>(null);
async function load() {
setLoading(true);
try {
setGames(await listGames());
} finally {
setLoading(false);
}
}
useEffect(() => {
load();
}, []);
async function handleCreate(e: FormEvent) {
e.preventDefault();
setError(null);
try {
const game = await createGame(name, description);
navigate(`/games/${game.id}`);
} catch {
setError("Spiel konnte nicht erstellt werden.");
}
}
return (
<div className="mx-auto max-w-3xl px-4 py-8">
<div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-bold text-amber-400">Spiele</h1>
<div className="flex gap-2">
<button
onClick={() => setShowCreate((v) => !v)}
className="rounded bg-amber-500 px-3 py-2 text-sm font-semibold text-slate-950 hover:bg-amber-400"
>
Neues Spiel
</button>
</div>
</div>
{showCreate && (
<form onSubmit={handleCreate} className="mb-6 flex flex-col gap-3 rounded-lg border border-slate-800 bg-slate-900 p-4">
<input
required
placeholder="Name des Spiels"
value={name}
onChange={(e) => setName(e.target.value)}
className="rounded bg-slate-800 px-3 py-2 outline-none focus:ring-2 focus:ring-amber-400"
/>
<textarea
placeholder="Kurzbeschreibung (Genre, Welt, ...)"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="rounded bg-slate-800 px-3 py-2 outline-none focus:ring-2 focus:ring-amber-400"
rows={3}
/>
<button
type="submit"
className="self-start rounded bg-amber-500 px-3 py-2 text-sm font-semibold text-slate-950 hover:bg-amber-400"
>
Erstellen
</button>
</form>
)}
{error && <p className="mb-4 text-sm text-red-400">{error}</p>}
{loading ? (
<p className="text-slate-400">Lädt…</p>
) : games.length === 0 ? (
<p className="text-slate-400">Noch keine Spiele vorhanden.</p>
) : (
<div className="flex flex-col gap-3">
{games.map((game) => (
<GameCard key={game.id} game={game} />
))}
</div>
)}
</div>
);
}
+67
View File
@@ -0,0 +1,67 @@
import { FormEvent, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useAuth } from "../hooks/useAuth";
export default function Login() {
const { login } = useAuth();
const navigate = useNavigate();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError(null);
setSubmitting(true);
try {
await login(email, password);
navigate("/games");
} catch {
setError("Anmeldung fehlgeschlagen. E-Mail oder Passwort falsch.");
} finally {
setSubmitting(false);
}
}
return (
<div className="mx-auto mt-16 max-w-sm px-4">
<h1 className="mb-6 text-2xl font-bold text-amber-400">Anmelden</h1>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<input
type="email"
required
placeholder="E-Mail"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="rounded bg-slate-800 px-3 py-2 outline-none focus:ring-2 focus:ring-amber-400"
/>
<input
type="password"
required
placeholder="Passwort"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="rounded bg-slate-800 px-3 py-2 outline-none focus:ring-2 focus:ring-amber-400"
/>
{error && <p className="text-sm text-red-400">{error}</p>}
<button
type="submit"
disabled={submitting}
className="rounded bg-amber-500 px-3 py-2 font-semibold text-slate-950 hover:bg-amber-400 disabled:opacity-50"
>
Anmelden
</button>
</form>
<div className="mt-4 flex justify-between text-sm text-slate-400">
<Link to="/register" className="hover:text-amber-400">
Registrieren
</Link>
<Link to="/forgot-password" className="hover:text-amber-400">
Passwort vergessen?
</Link>
</div>
</div>
);
}
+74
View File
@@ -0,0 +1,74 @@
import { FormEvent, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useAuth } from "../hooks/useAuth";
export default function Register() {
const { register } = useAuth();
const navigate = useNavigate();
const [email, setEmail] = useState("");
const [name, setName] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError(null);
setSubmitting(true);
try {
await register(email, password, name);
navigate("/games");
} catch {
setError("Registrierung fehlgeschlagen. E-Mail evtl. schon vergeben oder Passwort zu schwach.");
} finally {
setSubmitting(false);
}
}
return (
<div className="mx-auto mt-16 max-w-sm px-4">
<h1 className="mb-6 text-2xl font-bold text-amber-400">Registrieren</h1>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<input
type="text"
required
placeholder="Name"
value={name}
onChange={(e) => setName(e.target.value)}
className="rounded bg-slate-800 px-3 py-2 outline-none focus:ring-2 focus:ring-amber-400"
/>
<input
type="email"
required
placeholder="E-Mail"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="rounded bg-slate-800 px-3 py-2 outline-none focus:ring-2 focus:ring-amber-400"
/>
<input
type="password"
required
minLength={8}
placeholder="Passwort (mind. 8 Zeichen)"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="rounded bg-slate-800 px-3 py-2 outline-none focus:ring-2 focus:ring-amber-400"
/>
{error && <p className="text-sm text-red-400">{error}</p>}
<button
type="submit"
disabled={submitting}
className="rounded bg-amber-500 px-3 py-2 font-semibold text-slate-950 hover:bg-amber-400 disabled:opacity-50"
>
Konto erstellen
</button>
</form>
<div className="mt-4 text-sm text-slate-400">
<Link to="/login" className="hover:text-amber-400">
Schon registriert? Anmelden
</Link>
</div>
</div>
);
}
+64
View File
@@ -0,0 +1,64 @@
import { FormEvent, useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { resetPassword } from "../api/auth";
export default function ResetPassword() {
const [searchParams] = useSearchParams();
const token = searchParams.get("token") ?? "";
const navigate = useNavigate();
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState(false);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError(null);
try {
await resetPassword(token, password);
setDone(true);
setTimeout(() => navigate("/login"), 1500);
} catch {
setError("Zurücksetzen fehlgeschlagen. Der Link ist evtl. abgelaufen.");
}
}
if (!token) {
return (
<div className="mx-auto mt-16 max-w-sm px-4 text-slate-300">
Kein gültiger Reset-Link.{" "}
<Link to="/forgot-password" className="text-amber-400">
Neu anfordern
</Link>
</div>
);
}
return (
<div className="mx-auto mt-16 max-w-sm px-4">
<h1 className="mb-6 text-2xl font-bold text-amber-400">Neues Passwort setzen</h1>
{done ? (
<p className="text-slate-300">Passwort geändert. Du wirst weitergeleitet…</p>
) : (
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<input
type="password"
required
minLength={8}
placeholder="Neues Passwort"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="rounded bg-slate-800 px-3 py-2 outline-none focus:ring-2 focus:ring-amber-400"
/>
{error && <p className="text-sm text-red-400">{error}</p>}
<button
type="submit"
className="rounded bg-amber-500 px-3 py-2 font-semibold text-slate-950 hover:bg-amber-400"
>
Passwort setzen
</button>
</form>
)}
</div>
);
}