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:
@@ -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");
|
||||
}
|
||||
@@ -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}`);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}` : ""}`,
|
||||
);
|
||||
Reference in New Issue
Block a user