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
View File
+58
View File
@@ -0,0 +1,58 @@
import asyncio
from logging.config import fileConfig
from alembic import context
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from app.config import settings
from app.db import Base
# import model modules here so they register on Base.metadata before autogenerate runs
from app import models # noqa: F401
config = context.config
config.set_main_option("sqlalchemy.url", settings.database_url)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = settings.database_url
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_migrations_online() -> None:
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_migrations_online())
+26
View File
@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
@@ -0,0 +1,108 @@
"""initial schema
Revision ID: 48846ed4fe0e
Revises:
Create Date: 2026-08-31 15:38:20.816566
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import fastapi_users_db_sqlalchemy
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '48846ed4fe0e'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('users',
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('id', fastapi_users_db_sqlalchemy.generics.GUID(), nullable=False),
sa.Column('email', sa.String(length=320), nullable=False),
sa.Column('hashed_password', sa.String(length=1024), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('is_superuser', sa.Boolean(), nullable=False),
sa.Column('is_verified', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
op.create_table('characters',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('owner_id', sa.UUID(), nullable=False),
sa.Column('name', sa.String(length=200), nullable=False),
sa.Column('race', sa.String(length=100), nullable=True),
sa.Column('char_class', sa.String(length=100), nullable=True),
sa.Column('level', sa.Integer(), nullable=False),
sa.Column('stats', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('description', sa.Text(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_characters_owner_id'), 'characters', ['owner_id'], unique=False)
op.create_table('games',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('name', sa.String(length=200), nullable=False),
sa.Column('description', sa.Text(), nullable=False),
sa.Column('creator_id', sa.UUID(), nullable=False),
sa.Column('participation_code', sa.String(length=16), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['creator_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_games_created_at'), 'games', ['created_at'], unique=False)
op.create_index(op.f('ix_games_participation_code'), 'games', ['participation_code'], unique=True)
op.create_table('game_participants',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('game_id', sa.UUID(), nullable=False),
sa.Column('user_id', sa.UUID(), nullable=False),
sa.Column('character_id', sa.UUID(), nullable=True),
sa.Column('joined_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
sa.ForeignKeyConstraint(['game_id'], ['games.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('game_id', 'user_id', name='uq_game_participant')
)
op.create_index(op.f('ix_game_participants_game_id'), 'game_participants', ['game_id'], unique=False)
op.create_table('messages',
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column('game_id', sa.UUID(), nullable=False),
sa.Column('sender_type', sa.Text(), nullable=False),
sa.Column('user_id', sa.UUID(), nullable=True),
sa.Column('character_id', sa.UUID(), nullable=True),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint("sender_type IN ('player', 'dm', 'system')", name='ck_message_sender_type'),
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
sa.ForeignKeyConstraint(['game_id'], ['games.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_messages_created_at'), 'messages', ['created_at'], unique=False)
op.create_index(op.f('ix_messages_game_id'), 'messages', ['game_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_messages_game_id'), table_name='messages')
op.drop_index(op.f('ix_messages_created_at'), table_name='messages')
op.drop_table('messages')
op.drop_index(op.f('ix_game_participants_game_id'), table_name='game_participants')
op.drop_table('game_participants')
op.drop_index(op.f('ix_games_participation_code'), table_name='games')
op.drop_index(op.f('ix_games_created_at'), table_name='games')
op.drop_table('games')
op.drop_index(op.f('ix_characters_owner_id'), table_name='characters')
op.drop_table('characters')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_table('users')
# ### end Alembic commands ###
View File
+42
View File
@@ -0,0 +1,42 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.users import current_active_user
from app.db import get_async_session
from app.models.character import Character
from app.models.user import User
from app.schemas.character import CharacterRead
router = APIRouter(prefix="/api/characters", tags=["characters"])
@router.get("", response_model=list[CharacterRead])
async def list_my_characters(
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
) -> list[Character]:
characters = (
await session.execute(
select(Character)
.where(Character.owner_id == user.id)
.order_by(Character.created_at.desc())
)
).scalars().all()
return list(characters)
@router.get("/{character_id}", response_model=CharacterRead)
async def get_character(
character_id: uuid.UUID,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
) -> Character:
character = await session.get(Character, character_id)
if character is None:
raise HTTPException(status_code=404, detail="Character not found")
if character.owner_id != user.id:
raise HTTPException(status_code=403, detail="Not your character")
return character
+251
View File
@@ -0,0 +1,251 @@
import secrets
import string
import uuid
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.users import current_active_user
from app.db import get_async_session
from app.models.character import Character
from app.models.game import Game, GameParticipant
from app.models.message import Message
from app.models.user import User
from app.schemas.game import GameCreate, GameJoin, GameRead
from app.schemas.message import MessageRead
from app.ws_tickets import issue_ticket
router = APIRouter(prefix="/api/games", tags=["games"])
CODE_ALPHABET = string.ascii_uppercase + string.digits
def _generate_participation_code() -> str:
return "".join(secrets.choice(CODE_ALPHABET) for _ in range(8))
async def _serialize_games(
session: AsyncSession, games: list[Game], viewer_id: uuid.UUID
) -> list[GameRead]:
if not games:
return []
game_ids = [g.id for g in games]
creators = (
await session.execute(select(User.id, User.name).where(User.id.in_([g.creator_id for g in games])))
).all()
creator_names = {row.id: row.name for row in creators}
participant_rows = (
await session.execute(
select(GameParticipant.game_id, GameParticipant.user_id, GameParticipant.character_id, User.name)
.join(User, User.id == GameParticipant.user_id)
.where(GameParticipant.game_id.in_(game_ids))
)
).all()
participants_by_game: dict[uuid.UUID, list[str]] = {}
viewer_participation: dict[uuid.UUID, uuid.UUID | None] = {}
for game_id, participant_user_id, character_id, name in participant_rows:
participants_by_game.setdefault(game_id, []).append(name)
if participant_user_id == viewer_id:
viewer_participation[game_id] = character_id
result = []
for game in games:
names = participants_by_game.get(game.id, [])
is_participant = game.id in viewer_participation
result.append(
GameRead(
id=game.id,
name=game.name,
description=game.description,
creator_id=game.creator_id,
creator_name=creator_names.get(game.creator_id, "?"),
player_count=len(names),
player_names=names,
participation_code=game.participation_code if game.creator_id == viewer_id else None,
created_at=game.created_at,
is_participant=is_participant,
my_character_id=viewer_participation.get(game.id),
)
)
return result
@router.get("", response_model=list[GameRead])
async def list_games(
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
) -> list[GameRead]:
games = (
(await session.execute(select(Game).order_by(Game.created_at.desc()))).scalars().all()
)
return await _serialize_games(session, list(games), user.id)
@router.post("", response_model=GameRead)
async def create_game(
payload: GameCreate,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
) -> GameRead:
game = Game(
name=payload.name,
description=payload.description,
creator_id=user.id,
participation_code=_generate_participation_code(),
)
session.add(game)
await session.flush()
session.add(GameParticipant(game_id=game.id, user_id=user.id))
await session.commit()
await session.refresh(game)
serialized = await _serialize_games(session, [game], user.id)
return serialized[0]
@router.get("/{game_id}", response_model=GameRead)
async def get_game(
game_id: uuid.UUID,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
) -> GameRead:
game = await session.get(Game, game_id)
if game is None:
raise HTTPException(status_code=404, detail="Game not found")
serialized = await _serialize_games(session, [game], user.id)
return serialized[0]
async def _serialize_messages(session: AsyncSession, messages: list[Message]) -> list[MessageRead]:
if not messages:
return []
user_ids = {m.user_id for m in messages if m.user_id is not None}
character_ids = {m.character_id for m in messages if m.character_id is not None}
names: dict[uuid.UUID, str] = {}
if user_ids:
rows = (await session.execute(select(User.id, User.name).where(User.id.in_(user_ids)))).all()
names.update({row.id: row.name for row in rows})
char_names: dict[uuid.UUID, str] = {}
if character_ids:
rows = (
await session.execute(select(Character.id, Character.name).where(Character.id.in_(character_ids)))
).all()
char_names.update({row.id: row.name for row in rows})
return [
MessageRead(
id=m.id,
sender_type=m.sender_type,
user_id=m.user_id,
player_name=names.get(m.user_id) if m.user_id else None,
character_id=m.character_id,
character_name=char_names.get(m.character_id) if m.character_id else None,
content=m.content,
created_at=m.created_at,
)
for m in messages
]
async def _require_participant(session: AsyncSession, game_id: uuid.UUID, user_id: uuid.UUID) -> GameParticipant:
participant = (
await session.execute(
select(GameParticipant).where(
GameParticipant.game_id == game_id, GameParticipant.user_id == user_id
)
)
).scalar_one_or_none()
if participant is None:
raise HTTPException(status_code=403, detail="You have not joined this game")
return participant
@router.get("/{game_id}/messages", response_model=list[MessageRead])
async def get_recent_messages(
game_id: uuid.UUID,
limit: int = 5,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
) -> list[MessageRead]:
await _require_participant(session, game_id, user.id)
rows = (
await session.execute(
select(Message)
.where(Message.game_id == game_id)
.order_by(Message.created_at.desc(), Message.id.desc())
.limit(limit)
)
).scalars().all()
ordered = list(reversed(rows))
return await _serialize_messages(session, ordered)
@router.get("/{game_id}/messages/full", response_model=list[MessageRead])
async def get_full_messages(
game_id: uuid.UUID,
before_id: int | None = None,
limit: int = 100,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
) -> list[MessageRead]:
await _require_participant(session, game_id, user.id)
query = select(Message).where(Message.game_id == game_id)
if before_id is not None:
query = query.where(Message.id < before_id)
query = query.order_by(Message.id.desc()).limit(limit)
rows = (await session.execute(query)).scalars().all()
ordered = list(reversed(rows))
return await _serialize_messages(session, ordered)
@router.post("/{game_id}/join", response_model=GameRead)
async def join_game(
game_id: uuid.UUID,
payload: GameJoin,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
) -> GameRead:
game = await session.get(Game, game_id)
if game is None:
raise HTTPException(status_code=404, detail="Game not found")
if not secrets.compare_digest(payload.participation_code, game.participation_code):
raise HTTPException(status_code=403, detail="Invalid participation code")
existing = (
await session.execute(
select(GameParticipant).where(
GameParticipant.game_id == game_id, GameParticipant.user_id == user.id
)
)
).scalar_one_or_none()
if existing is None:
session.add(
GameParticipant(game_id=game_id, user_id=user.id, character_id=payload.character_id)
)
elif payload.character_id is not None:
existing.character_id = payload.character_id
await session.commit()
serialized = await _serialize_games(session, [game], user.id)
return serialized[0]
@router.post("/{game_id}/ws-ticket")
async def create_ws_ticket(
game_id: uuid.UUID,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
) -> dict[str, str]:
await _require_participant(session, game_id, user.id)
return {"ticket": issue_ticket(game_id, user.id)}
+136
View File
@@ -0,0 +1,136 @@
import asyncio
import logging
import uuid
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from sqlalchemy import select
from app.db import async_session_maker
from app.llm.orchestrator import run_dm_turn
from app.models.game import GameParticipant
from app.models.message import Message
from app.ws_tickets import consume_ticket
logger = logging.getLogger("app.ws_game")
router = APIRouter()
class ConnectionManager:
def __init__(self) -> None:
self._rooms: dict[uuid.UUID, set[WebSocket]] = {}
self._locks: dict[uuid.UUID, asyncio.Lock] = {}
def connect(self, game_id: uuid.UUID, websocket: WebSocket) -> None:
self._rooms.setdefault(game_id, set()).add(websocket)
def disconnect(self, game_id: uuid.UUID, websocket: WebSocket) -> None:
room = self._rooms.get(game_id)
if room is not None:
room.discard(websocket)
if not room:
self._rooms.pop(game_id, None)
async def broadcast(self, game_id: uuid.UUID, payload: dict) -> None:
for ws in list(self._rooms.get(game_id, ())):
try:
await ws.send_json(payload)
except Exception: # noqa: BLE001 — a dead socket shouldn't break the broadcast
self.disconnect(game_id, ws)
def get_lock(self, game_id: uuid.UUID) -> asyncio.Lock:
return self._locks.setdefault(game_id, asyncio.Lock())
manager = ConnectionManager()
async def _serialize_message(session, message: Message) -> dict:
from app.models.character import Character
from app.models.user import User
player_name = None
character_name = None
if message.user_id is not None:
user = await session.get(User, message.user_id)
player_name = user.name if user else None
if message.character_id is not None:
character = await session.get(Character, message.character_id)
character_name = character.name if character else None
return {
"id": message.id,
"sender_type": message.sender_type,
"user_id": str(message.user_id) if message.user_id else None,
"player_name": player_name,
"character_id": str(message.character_id) if message.character_id else None,
"character_name": character_name,
"content": message.content,
"created_at": message.created_at.isoformat(),
}
@router.websocket("/ws/games/{game_id}")
async def game_websocket(websocket: WebSocket, game_id: uuid.UUID, ticket: str) -> None:
user_id = consume_ticket(ticket, game_id)
if user_id is None:
await websocket.close(code=4401)
return
await websocket.accept()
manager.connect(game_id, websocket)
try:
while True:
data = await websocket.receive_json()
if data.get("type") != "message":
continue
content = (data.get("content") or "").strip()
if not content:
continue
async with manager.get_lock(game_id):
async with async_session_maker() as session:
participant = (
await session.execute(
select(GameParticipant).where(
GameParticipant.game_id == game_id,
GameParticipant.user_id == user_id,
)
)
).scalar_one_or_none()
if participant is None:
await websocket.send_json({"type": "error", "detail": "not a participant"})
continue
player_message = Message(
game_id=game_id,
sender_type="player",
user_id=user_id,
character_id=participant.character_id,
content=content,
)
session.add(player_message)
await session.commit()
await session.refresh(player_message)
await manager.broadcast(
game_id,
{"type": "message", "message": await _serialize_message(session, player_message)},
)
try:
dm_message = await run_dm_turn(session, game_id)
except Exception: # noqa: BLE001
logger.exception("DM turn failed for game %s", game_id)
await websocket.send_json({"type": "error", "detail": "dm_turn_failed"})
continue
await manager.broadcast(
game_id,
{"type": "message", "message": await _serialize_message(session, dm_message)},
)
except WebSocketDisconnect:
pass
finally:
manager.disconnect(game_id, websocket)
View File
+30
View File
@@ -0,0 +1,30 @@
import logging
import aiosmtplib
from email.message import EmailMessage
from app.config import settings
logger = logging.getLogger("app.auth.email")
async def send_email(to: str, subject: str, body: str) -> None:
"""Send a plaintext email, or log it to the console if no SMTP server is configured (dev mode)."""
if not settings.smtp_host:
logger.info("=== DEV EMAIL (no SMTP configured) ===\nTo: %s\nSubject: %s\n\n%s", to, subject, body)
return
message = EmailMessage()
message["From"] = settings.smtp_from
message["To"] = to
message["Subject"] = subject
message.set_content(body)
await aiosmtplib.send(
message,
hostname=settings.smtp_host,
port=settings.smtp_port,
username=settings.smtp_user or None,
password=settings.smtp_password or None,
start_tls=True,
)
+68
View File
@@ -0,0 +1,68 @@
import uuid
from collections.abc import AsyncGenerator
from fastapi import Depends, Request
from fastapi_users import BaseUserManager, FastAPIUsers, UUIDIDMixin
from fastapi_users.authentication import (
AuthenticationBackend,
BearerTransport,
JWTStrategy,
)
from fastapi_users.db import SQLAlchemyUserDatabase
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.email import send_email
from app.config import settings
from app.db import get_async_session
from app.models.user import User
JWT_LIFETIME_SECONDS = 60 * 60 * 24 * 7 # 7 days
async def get_user_db(
session: AsyncSession = Depends(get_async_session),
) -> AsyncGenerator[SQLAlchemyUserDatabase, None]:
yield SQLAlchemyUserDatabase(session, User)
class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
reset_password_token_secret = settings.reset_password_secret
verification_token_secret = settings.verification_token_secret
async def on_after_forgot_password(
self, user: User, token: str, request: Request | None = None
) -> None:
reset_url = f"{settings.frontend_origin}/reset-password?token={token}"
await send_email(
to=user.email,
subject="Passwort zurücksetzen — DungeonsDragons",
body=(
f"Hallo {user.name},\n\n"
f"klicke auf den folgenden Link, um dein Passwort zurückzusetzen:\n{reset_url}\n\n"
"Falls du das nicht angefordert hast, ignoriere diese E-Mail."
),
)
async def get_user_manager(
user_db: SQLAlchemyUserDatabase = Depends(get_user_db),
) -> AsyncGenerator[UserManager, None]:
yield UserManager(user_db)
bearer_transport = BearerTransport(tokenUrl="auth/jwt/login")
def get_jwt_strategy() -> JWTStrategy:
return JWTStrategy(secret=settings.jwt_secret, lifetime_seconds=JWT_LIFETIME_SECONDS)
auth_backend = AuthenticationBackend(
name="jwt",
transport=bearer_transport,
get_strategy=get_jwt_strategy,
)
fastapi_users = FastAPIUsers[User, uuid.UUID](get_user_manager, [auth_backend])
current_active_user = fastapi_users.current_user(active=True)
+26
View File
@@ -0,0 +1,26 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
database_url: str = "postgresql+asyncpg://dnd:changeme@postgres:5432/dnd"
xai_api_key: str = ""
xai_base_url: str = "https://api.x.ai/v1"
dm_model: str = "grok-4.6"
jwt_secret: str = "dev-insecure-secret-change-me"
reset_password_secret: str = "dev-insecure-secret-change-me"
verification_token_secret: str = "dev-insecure-secret-change-me"
smtp_host: str = ""
smtp_port: int = 587
smtp_user: str = ""
smtp_password: str = ""
smtp_from: str = "no-reply@dungeonsdragons.tstratmann.de"
frontend_origin: str = "http://localhost:5173"
settings = Settings()
+18
View File
@@ -0,0 +1,18 @@
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.config import settings
engine = create_async_engine(settings.database_url)
async_session_maker = async_sessionmaker(engine, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def get_async_session() -> AsyncGenerator[AsyncSession, None]:
async with async_session_maker() as session:
yield session
View File
+18
View File
@@ -0,0 +1,18 @@
from functools import lru_cache
from pathlib import Path
from openai import AsyncOpenAI
from app.config import settings
PROMPTS_DIR = Path(__file__).parent / "prompts"
@lru_cache
def get_dm_system_prompt() -> str:
return (PROMPTS_DIR / "dm_system_prompt.txt").read_text(encoding="utf-8")
@lru_cache
def get_llm_client() -> AsyncOpenAI:
return AsyncOpenAI(api_key=settings.xai_api_key, base_url=settings.xai_base_url)
+59
View File
@@ -0,0 +1,59 @@
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.character import Character
from app.models.message import Message
from app.models.user import User
# Rough char-based budget for Phase 1 (~4 chars/token, leaves headroom for system prompt + output).
MAX_CONTEXT_CHARS = 40_000
async def build_context(session: AsyncSession, game_id: uuid.UUID) -> list[dict]:
messages = (
await session.execute(
select(Message).where(Message.game_id == game_id).order_by(Message.id.asc())
)
).scalars().all()
if not messages:
return []
user_ids = {m.user_id for m in messages if m.user_id is not None}
character_ids = {m.character_id for m in messages if m.character_id is not None}
names: dict[uuid.UUID, str] = {}
if user_ids:
rows = (await session.execute(select(User.id, User.name).where(User.id.in_(user_ids)))).all()
names = {row.id: row.name for row in rows}
char_names: dict[uuid.UUID, str] = {}
if character_ids:
rows = (
await session.execute(select(Character.id, Character.name).where(Character.id.in_(character_ids)))
).all()
char_names = {row.id: row.name for row in rows}
entries: list[dict] = []
for m in messages:
if m.sender_type == "dm":
entries.append({"role": "assistant", "content": m.content})
elif m.sender_type == "player":
player_name = names.get(m.user_id, "Spieler") if m.user_id else "Spieler"
character_name = char_names.get(m.character_id) if m.character_id else None
label = f"{player_name} ({character_name})" if character_name else player_name
entries.append({"role": "user", "content": f"{label}: {m.content}"})
# sender_type == "system" messages are not sent to the model in Phase 1
# Keep the newest entries within the char budget, dropping oldest first.
total = 0
kept: list[dict] = []
for entry in reversed(entries):
total += len(entry["content"])
if total > MAX_CONTEXT_CHARS and kept:
break
kept.append(entry)
kept.reverse()
return kept
+81
View File
@@ -0,0 +1,81 @@
import json
import logging
import uuid
from sqlalchemy.ext.asyncio import AsyncSession
logger = logging.getLogger("app.llm.orchestrator")
from app.config import settings
from app.llm.client import get_dm_system_prompt, get_llm_client
from app.llm.context import build_context
from app.llm.tools import character_sheet, dice
from app.models.message import Message
MAX_TOOL_ROUNDS = 5
MAX_TOKENS = 4096
TOOLS = [
{"type": "function", "function": dice.TOOL_SCHEMA},
{"type": "function", "function": character_sheet.TOOL_SCHEMA},
]
async def _execute_tool_call(
session: AsyncSession, game_id: uuid.UUID, tool_name: str, tool_input: dict
) -> dict:
"""Runs one tool call. Errors are returned as a payload (not raised), so the DM sees them
as a tool result and can recover instead of the whole turn crashing."""
try:
if tool_name == "roll_dice":
return dice.roll(tool_input["notation"])
if tool_name == "upsert_character_sheet":
return await character_sheet.upsert(session, game_id, tool_input)
return {"error": f"Unknown tool {tool_name!r}"}
except Exception as exc: # noqa: BLE001
logger.warning("Tool call %s failed: %s", tool_name, exc)
await session.rollback()
return {"error": str(exc)}
async def run_dm_turn(session: AsyncSession, game_id: uuid.UUID) -> Message:
client = get_llm_client()
messages = [{"role": "system", "content": get_dm_system_prompt()}]
messages.extend(await build_context(session, game_id))
final_text = ""
for _ in range(MAX_TOOL_ROUNDS):
response = await client.chat.completions.create(
model=settings.dm_model,
max_tokens=MAX_TOKENS,
messages=messages,
tools=TOOLS,
)
choice = response.choices[0]
message = choice.message
final_text = message.content or ""
if not message.tool_calls:
break
messages.append(message.model_dump(exclude_unset=True))
for tool_call in message.tool_calls:
tool_input = json.loads(tool_call.function.arguments)
result = await _execute_tool_call(session, game_id, tool_call.function.name, tool_input)
messages.append(
{
"role": "tool",
"tool_call_id": tool_call.id,
"content": json.dumps(result),
}
)
else:
if not final_text:
final_text = "(Der Dungeon Master braucht einen Moment länger als erwartet — bitte versuche es erneut.)"
dm_message = Message(game_id=game_id, sender_type="dm", content=final_text)
session.add(dm_message)
await session.commit()
await session.refresh(dm_message)
return dm_message
@@ -0,0 +1,32 @@
Rolle
Du bist ein erfahrener D&D Dungeon Master und Regelexperte (Standard: D&D 5e, sofern nichts anderes vereinbart wird). Du erschaffst lebendige Abenteuer, leitest die Spieler souverän durch die Sitzung und sorgst für ein rundes Spielerlebnis – von der ersten Frage bis zum Abspann der Session.
Phase 1: Session 0 (Vorbereitung)
Bevor das Abenteuer beginnt, lernst du deine Spieler aktiv kennen. Warte nicht auf Informationen – stelle gezielt Fragen, eine nach der anderen, nicht alle auf einmal:
1. Anzahl der Spieler und ihre Vornamen.
2. Erfahrungsstand: Komplette Anfänger, Gelegenheitsspieler oder Regelkenner? Frage das pro Spieler ab, falls die Gruppe gemischt ist.
3. Charaktere: Wollen sie eigene, fertige Charaktere mitbringen (Name, Klasse, Rasse, Stats) oder sollst du sie durch die Charaktererstellung führen?
4. Regelkenntnis: Brauchen sie Erklärungen zu Grundmechaniken (Würfelwürfe, Angriffe, Rettungswürfe) oder reicht ein knapper Hinweis?
5. Setting/Welt: Vorgefertigte Welt (z. B. Forgotten Realms), eine von dir erfundene Welt, oder wünschen sie sich ein bestimmtes Thema/Genre (High Fantasy, düster, humorvoll, Horror, Piraten, ...)?
6. Umfang: Einzelnes One-Shot-Abenteuer (eine Sitzung) oder eine längere Kampagne über mehrere Sitzungen? Grobe Zeit, die sie investieren wollen?
7. Inhaltliche Grenzen: Gibt es Themen, die sie im Spiel nicht wollen (Triggerwarnungen, Gewaltlevel, etc.)?
Passe deinen Hilfe-Level dynamisch an: Anfängern erklärst du Mechaniken proaktiv und schlägst Optionen vor ("Du kannst jetzt A, B oder C tun"). Erfahrenen Spielern gibst du nur auf Nachfrage Regeldetails und lässt ihnen mehr erzählerische Freiheit.
Phase 2: Das Spiel leiten
* Sobald Charaktere feststehen, sprichst du die Spieler in Spielszenen mit ihrem Charakternamen an. Außerhalb der Spielszenen (Regelfragen, Meta-Absprachen) sprichst du sie mit ihrem echten Vornamen an – und zwar immer per "Du", niemals per "Sie".
* Trenne klar zwischen IC (In Character – Spielwelt, Dialoge, Beschreibungen) und OOC (Out of Character – Regelklärungen, Meta-Fragen). Kennzeichne OOC-Kommentare deutlich, z. B. mit "(OOC: ...)".
* Führe die Spieler aktiv durch die Handlung: beschreibe Szenen, NPCs und Umgebungen atmosphärisch, biete konkrete Handlungsoptionen an, wenn Spieler unsicher wirken, und treibe die Geschichte voran statt nur zu reagieren.
* Für Würfelproben: Sage klar, welcher Wurf nötig ist (z. B. "Mach eine Geschicklichkeitsrettung, Schwierigkeit 13") und was Erfolg/Misserfolg bedeutet. Wenn Spieler keine echten Würfel haben, kannst du Würfe simulieren – frage vorher, was sie bevorzugen.
* Behalte den Weltzustand im Blick: NPC-Namen, Orte, offene Handlungsstränge, Inventar und Fortschritt der Charaktere bleiben über die Sitzung(en) hinweg konsistent.
* Baue die Sitzung dramaturgisch auf: Einstieg, ansteigende Spannung, Höhepunkt, Auflösung – bei Kampagnen jede Sitzung mit einem Cliffhanger oder klaren Abschluss beenden.
* Im Spiel sprichst du im Ton und Slang passend zur Welt und den Charakteren (z. B. archaisch-episch in High Fantasy, derb-direkt bei Piraten). Außerhalb bleibst du locker, freundlich und klar in der Ansprache.
Regeln & Wissensquellen
* Du bist Regelexperte: Bei Unsicherheiten der Spieler erklärst du Mechaniken (Kämpfe, Zauber, Fertigkeiten, Zustände) verständlich und korrekt.
* Wenn Handbücher/Regelwerke hochgeladen wurden, richtest du dich strikt nach deren Inhalt und zitierst/nennst bei Bedarf die relevante Regel. Wurde nichts hochgeladen, greifst du auf dein allgemeines D&D-5e-Regelwissen zurück und weist transparent darauf hin, falls du bei einer Detailregel unsicher bist, statt sie zu erfinden.
* Bei Widersprüchen zwischen Spielspaß und exakter Regelauslegung fragst du kurz nach, welche Priorität die Gruppe bevorzugt (z. B. eher erzählerisch-locker oder regelgenau).
Sprache
Antworte immer in der Sprache, in der die Spieler mit dir kommunizieren. Wechselt die Gruppe die Sprache, wechselst du mit.
View File
+89
View File
@@ -0,0 +1,89 @@
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.character import Character
from app.models.game import GameParticipant
from app.models.user import User
TOOL_SCHEMA = {
"name": "upsert_character_sheet",
"description": (
"Create or update a player's character sheet as facts about them are confirmed or change during play "
"(name, race, class, ability stats, abilities, backstory). Call this proactively as soon as details are "
"settled — do not wait until the end of the session."
),
"parameters": {
"type": "object",
"properties": {
"player_name": {
"type": "string",
"description": "The real first name of the player this character belongs to, exactly as they introduced themselves.",
},
"character_id": {
"type": "string",
"description": "Omit when creating a new character. Provide the existing character's id when updating one.",
},
"name": {"type": "string"},
"race": {"type": "string"},
"char_class": {"type": "string"},
"stats": {
"type": "object",
"description": "Ability scores, e.g. {\"STR\": 15, \"DEX\": 12, \"CON\": 14, \"INT\": 10, \"WIS\": 8, \"CHA\": 13}",
},
"description": {
"type": "string",
"description": "Free-text description: special abilities, oaths, backstory, anything relevant.",
},
},
"required": ["player_name"],
},
}
async def upsert(session: AsyncSession, game_id: uuid.UUID, tool_input: dict) -> dict:
player_name = tool_input["player_name"]
participant_row = (
await session.execute(
select(GameParticipant, User)
.join(User, User.id == GameParticipant.user_id)
.where(GameParticipant.game_id == game_id, User.name == player_name)
)
).first()
if participant_row is None:
raise ValueError(f"No participant named {player_name!r} found in this game")
participant, user = participant_row
character_id = tool_input.get("character_id")
character: Character | None = None
if character_id:
character = await session.get(Character, uuid.UUID(character_id))
if character is None or character.owner_id != user.id:
raise ValueError(f"Character {character_id!r} does not belong to {player_name!r}")
elif participant.character_id is not None:
# The player already has a character in this game (set by an earlier call in this
# session) — reuse it instead of creating a duplicate when the model omits character_id.
character = await session.get(Character, participant.character_id)
if character is None:
character = Character(owner_id=user.id, name=tool_input.get("name", "Unbenannt"), stats={})
session.add(character)
for field in ("name", "race", "char_class", "description"):
if field in tool_input and tool_input[field] is not None:
setattr(character, field, tool_input[field])
if "stats" in tool_input and tool_input["stats"] is not None:
character.stats = {**(character.stats or {}), **tool_input["stats"]}
await session.flush()
if participant.character_id != character.id:
participant.character_id = character.id
await session.commit()
await session.refresh(character)
return {"character_id": str(character.id), "name": character.name}
+50
View File
@@ -0,0 +1,50 @@
import re
from random import SystemRandom
_rng = SystemRandom()
_NOTATION_RE = re.compile(r"^\s*(\d*)d(\d+)\s*(?:([+-])\s*(\d+))?\s*$", re.IGNORECASE)
TOOL_SCHEMA = {
"name": "roll_dice",
"description": (
"Roll dice using standard D&D notation (e.g. '1d20+5', '2d6', 'd8-1'). "
"Always use this tool for any dice roll that affects the game — never invent or guess a result yourself."
),
"parameters": {
"type": "object",
"properties": {
"notation": {
"type": "string",
"description": "Dice notation, e.g. '1d20+5'",
},
"reason": {
"type": "string",
"description": "Short reason for the roll, e.g. 'Geschicklichkeitsrettung, Schwierigkeit 13'",
},
},
"required": ["notation"],
},
}
def roll(notation: str) -> dict:
match = _NOTATION_RE.match(notation)
if not match:
raise ValueError(f"Invalid dice notation: {notation!r}")
count_str, sides_str, sign, modifier_str = match.groups()
count = int(count_str) if count_str else 1
sides = int(sides_str)
if not (1 <= count <= 100) or not (2 <= sides <= 1000):
raise ValueError(f"Dice notation out of allowed range: {notation!r}")
modifier = int(modifier_str) if modifier_str else 0
if sign == "-":
modifier = -modifier
rolls = [_rng.randint(1, sides) for _ in range(count)]
total = sum(rolls) + modifier
return {"notation": notation, "rolls": rolls, "modifier": modifier, "total": total}
+44
View File
@@ -0,0 +1,44 @@
import logging
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.routes_characters import router as characters_router
from app.api.routes_games import router as games_router
from app.api.ws_game import router as ws_router
from app.auth.users import auth_backend, fastapi_users
from app.config import settings
from app.schemas.user import UserCreate, UserRead, UserUpdate
logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(name)s:%(message)s")
app = FastAPI(title="DungeonsDragons")
app.add_middleware(
CORSMiddleware,
allow_origins=[settings.frontend_origin],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(
fastapi_users.get_auth_router(auth_backend), prefix="/auth/jwt", tags=["auth"]
)
app.include_router(
fastapi_users.get_register_router(UserRead, UserCreate), prefix="/auth", tags=["auth"]
)
app.include_router(
fastapi_users.get_reset_password_router(), prefix="/auth", tags=["auth"]
)
app.include_router(
fastapi_users.get_users_router(UserRead, UserUpdate), prefix="/users", tags=["users"]
)
app.include_router(games_router)
app.include_router(characters_router)
app.include_router(ws_router)
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok"}
+6
View File
@@ -0,0 +1,6 @@
from app.models.character import Character
from app.models.game import Game, GameParticipant
from app.models.message import Message
from app.models.user import User
__all__ = ["User", "Game", "GameParticipant", "Character", "Message"]
+29
View File
@@ -0,0 +1,29 @@
import uuid
from datetime import datetime
from sqlalchemy import ForeignKey, Integer, String, Text, func
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db import Base
class Character(Base):
__tablename__ = "characters"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
owner_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(String(length=200), nullable=False)
race: Mapped[str | None] = mapped_column(String(length=100), nullable=True)
char_class: Mapped[str | None] = mapped_column(String(length=100), nullable=True)
level: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
stats: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
created_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False)
updated_at: Mapped[datetime] = mapped_column(
server_default=func.now(), onupdate=func.now(), nullable=False
)
+46
View File
@@ -0,0 +1,46 @@
import uuid
from datetime import datetime
from sqlalchemy import ForeignKey, String, Text, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db import Base
class Game(Base):
__tablename__ = "games"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
name: Mapped[str] = mapped_column(String(length=200), nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
creator_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id"), nullable=False
)
participation_code: Mapped[str] = mapped_column(
String(length=16), unique=True, nullable=False, index=True
)
created_at: Mapped[datetime] = mapped_column(
server_default=func.now(), nullable=False, index=True
)
class GameParticipant(Base):
__tablename__ = "game_participants"
__table_args__ = (UniqueConstraint("game_id", "user_id", name="uq_game_participant"),)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
game_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("games.id", ondelete="CASCADE"), nullable=False, index=True
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id"), nullable=False
)
character_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("characters.id"), nullable=True
)
joined_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False)
+31
View File
@@ -0,0 +1,31 @@
import uuid
from datetime import datetime
from sqlalchemy import BigInteger, CheckConstraint, ForeignKey, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db import Base
class Message(Base):
__tablename__ = "messages"
__table_args__ = (
CheckConstraint("sender_type IN ('player', 'dm', 'system')", name="ck_message_sender_type"),
)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
game_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("games.id", ondelete="CASCADE"), nullable=False, index=True
)
sender_type: Mapped[str] = mapped_column(Text, nullable=False)
user_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id"), nullable=True
)
character_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("characters.id"), nullable=True
)
content: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = mapped_column(
server_default=func.now(), nullable=False, index=True
)
+13
View File
@@ -0,0 +1,13 @@
import uuid
from fastapi_users.db import SQLAlchemyBaseUserTableUUID
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from app.db import Base
class User(SQLAlchemyBaseUserTableUUID, Base):
__tablename__ = "users"
name: Mapped[str] = mapped_column(String(length=100), nullable=False)
View File
+25
View File
@@ -0,0 +1,25 @@
import uuid
from datetime import datetime
from pydantic import BaseModel
class CharacterRead(BaseModel):
id: uuid.UUID
owner_id: uuid.UUID
name: str
race: str | None
char_class: str | None
level: int
stats: dict
description: str
created_at: datetime
updated_at: datetime
class CharacterSummary(BaseModel):
id: uuid.UUID
name: str
race: str | None
char_class: str | None
level: int
+28
View File
@@ -0,0 +1,28 @@
import uuid
from datetime import datetime
from pydantic import BaseModel
class GameCreate(BaseModel):
name: str
description: str = ""
class GameRead(BaseModel):
id: uuid.UUID
name: str
description: str
creator_id: uuid.UUID
creator_name: str
player_count: int
player_names: list[str]
participation_code: str | None # only populated for the creator
created_at: datetime
is_participant: bool
my_character_id: uuid.UUID | None
class GameJoin(BaseModel):
participation_code: str
character_id: uuid.UUID | None = None
+19
View File
@@ -0,0 +1,19 @@
import uuid
from datetime import datetime
from pydantic import BaseModel
class MessageRead(BaseModel):
id: int
sender_type: str
user_id: uuid.UUID | None
player_name: str | None
character_id: uuid.UUID | None
character_name: str | None
content: str
created_at: datetime
class MessageCreate(BaseModel):
content: str
+15
View File
@@ -0,0 +1,15 @@
import uuid
from fastapi_users import schemas
class UserRead(schemas.BaseUser[uuid.UUID]):
name: str
class UserCreate(schemas.BaseUserCreate):
name: str
class UserUpdate(schemas.BaseUserUpdate):
name: str | None = None
+24
View File
@@ -0,0 +1,24 @@
import secrets
import time
import uuid
TICKET_TTL_SECONDS = 30
_tickets: dict[str, tuple[uuid.UUID, uuid.UUID, float]] = {} # ticket -> (game_id, user_id, expires_at)
def issue_ticket(game_id: uuid.UUID, user_id: uuid.UUID) -> str:
ticket = secrets.token_urlsafe(32)
_tickets[ticket] = (game_id, user_id, time.monotonic() + TICKET_TTL_SECONDS)
return ticket
def consume_ticket(ticket: str, game_id: uuid.UUID) -> uuid.UUID | None:
"""Single-use: returns the user_id if the ticket is valid for this game and not expired, else None."""
entry = _tickets.pop(ticket, None)
if entry is None:
return None
ticket_game_id, user_id, expires_at = entry
if ticket_game_id != game_id or time.monotonic() > expires_at:
return None
return user_id