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
+52
View File
@@ -0,0 +1,52 @@
import { apiFetch, setToken } from "./client";
export interface User {
id: string;
email: string;
name: string;
is_active: boolean;
is_superuser: boolean;
is_verified: boolean;
}
export async function login(email: string, password: string): Promise<void> {
const body = new URLSearchParams();
body.set("username", email);
body.set("password", password);
const response = await fetch("/auth/jwt/login", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
});
if (!response.ok) {
throw new Error("Login fehlgeschlagen — bitte E-Mail und Passwort prüfen.");
}
const data = await response.json();
setToken(data.access_token);
}
export async function register(email: string, password: string, name: string): Promise<void> {
await apiFetch("/auth/register", {
method: "POST",
body: JSON.stringify({ email, password, name }),
});
}
export async function forgotPassword(email: string): Promise<void> {
await apiFetch("/auth/forgot-password", {
method: "POST",
body: JSON.stringify({ email }),
});
}
export async function resetPassword(token: string, password: string): Promise<void> {
await apiFetch("/auth/reset-password", {
method: "POST",
body: JSON.stringify({ token, password }),
});
}
export async function getMe(): Promise<User> {
return apiFetch<User>("/users/me");
}
+18
View File
@@ -0,0 +1,18 @@
import { apiFetch } from "./client";
export interface Character {
id: string;
owner_id: string;
name: string;
race: string | null;
char_class: string | null;
level: number;
stats: Record<string, number>;
description: string;
created_at: string;
updated_at: string;
}
export const listCharacters = () => apiFetch<Character[]>("/api/characters");
export const getCharacter = (id: string) => apiFetch<Character>(`/api/characters/${id}`);
+46
View File
@@ -0,0 +1,46 @@
const TOKEN_KEY = "dnd_token";
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
export function setToken(token: string): void {
localStorage.setItem(TOKEN_KEY, token);
}
export function clearToken(): void {
localStorage.removeItem(TOKEN_KEY);
}
export class ApiError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
}
export async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = getToken();
const headers = new Headers(options.headers);
if (token) headers.set("Authorization", `Bearer ${token}`);
if (options.body && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
const response = await fetch(path, { ...options, headers });
if (!response.ok) {
let detail = response.statusText;
try {
const body = await response.json();
detail = body.detail ? JSON.stringify(body.detail) : detail;
} catch {
// ignore — keep statusText
}
throw new ApiError(response.status, detail);
}
if (response.status === 204) return undefined as T;
return (await response.json()) as T;
}
+47
View File
@@ -0,0 +1,47 @@
import { apiFetch } from "./client";
export interface Game {
id: string;
name: string;
description: string;
creator_id: string;
creator_name: string;
player_count: number;
player_names: string[];
participation_code: string | null;
created_at: string;
is_participant: boolean;
my_character_id: string | null;
}
export interface Message {
id: number;
sender_type: "player" | "dm" | "system";
user_id: string | null;
player_name: string | null;
character_id: string | null;
character_name: string | null;
content: string;
created_at: string;
}
export const listGames = () => apiFetch<Game[]>("/api/games");
export const createGame = (name: string, description: string) =>
apiFetch<Game>("/api/games", { method: "POST", body: JSON.stringify({ name, description }) });
export const getGame = (id: string) => apiFetch<Game>(`/api/games/${id}`);
export const joinGame = (id: string, participation_code: string, character_id?: string) =>
apiFetch<Game>(`/api/games/${id}/join`, {
method: "POST",
body: JSON.stringify({ participation_code, character_id }),
});
export const getRecentMessages = (id: string, limit = 5) =>
apiFetch<Message[]>(`/api/games/${id}/messages?limit=${limit}`);
export const getFullMessages = (id: string, beforeId?: number, limit = 100) =>
apiFetch<Message[]>(
`/api/games/${id}/messages/full?limit=${limit}${beforeId ? `&before_id=${beforeId}` : ""}`,
);