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,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["--prefix", "frontend", "run", "dev"],
|
||||||
|
"port": 5173
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Copy this file to .env and fill in real values. Never commit .env.
|
||||||
|
|
||||||
|
# x.ai API (Grok) — DM model. Get a key at https://console.x.ai
|
||||||
|
XAI_API_KEY=
|
||||||
|
|
||||||
|
# Auth secrets — generate with: python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||||
|
JWT_SECRET=
|
||||||
|
RESET_PASSWORD_SECRET=
|
||||||
|
VERIFICATION_TOKEN_SECRET=
|
||||||
|
|
||||||
|
# Postgres
|
||||||
|
POSTGRES_USER=dnd
|
||||||
|
POSTGRES_PASSWORD=
|
||||||
|
POSTGRES_DB=dnd
|
||||||
|
DATABASE_URL=postgresql+asyncpg://dnd:changeme@postgres:5432/dnd
|
||||||
|
|
||||||
|
# SMTP (password reset emails). Leave empty in dev — emails are logged to console instead.
|
||||||
|
SMTP_HOST=
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=
|
||||||
|
SMTP_PASSWORD=
|
||||||
|
SMTP_FROM=no-reply@dungeonsdragons.tstratmann.de
|
||||||
|
|
||||||
|
# Frontend / CORS
|
||||||
|
FRONTEND_ORIGIN=http://localhost:5173
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
.env
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg-info/
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
caddy_data/
|
||||||
|
caddy_config/
|
||||||
|
pgdata/
|
||||||
|
frontend/dist/
|
||||||
|
*.tsbuildinfo
|
||||||
|
.DS_Store
|
||||||
+55685
File diff suppressed because it is too large
Load Diff
+45744
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libpq-dev gcc \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
[alembic]
|
||||||
|
script_location = app/alembic
|
||||||
|
prepend_sys_path = .
|
||||||
|
version_path_separator = os
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARNING
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARNING
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
@@ -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())
|
||||||
@@ -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 ###
|
||||||
@@ -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
|
||||||
@@ -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)}
|
||||||
@@ -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)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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}
|
||||||
@@ -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}
|
||||||
@@ -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"}
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fastapi==0.115.6
|
||||||
|
uvicorn[standard]==0.34.0
|
||||||
|
sqlalchemy[asyncio]==2.0.36
|
||||||
|
asyncpg==0.30.0
|
||||||
|
alembic==1.14.0
|
||||||
|
pydantic-settings==2.7.0
|
||||||
|
fastapi-users[sqlalchemy]==14.0.1
|
||||||
|
openai>=1.50.0,<2.0.0
|
||||||
|
aiosmtplib==3.0.2
|
||||||
|
python-multipart==0.0.20
|
||||||
|
websockets==14.1
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build: ./backend
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file: .env
|
||||||
|
volumes:
|
||||||
|
- ./backend/app:/app/app
|
||||||
|
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
FROM node:22-slim AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json .
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM scratch AS dist
|
||||||
|
COPY --from=build /app/dist /
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>DungeonsDragons</title>
|
||||||
|
</head>
|
||||||
|
<body class="bg-slate-950 text-slate-100">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+2769
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "dnd-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.28.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.12",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.49",
|
||||||
|
"tailwindcss": "^3.4.15",
|
||||||
|
"typescript": "^5.6.3",
|
||||||
|
"vite": "^5.4.11"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { Navigate, Route, Routes } from "react-router-dom";
|
||||||
|
|
||||||
|
import Navbar from "./components/Navbar";
|
||||||
|
import { AuthProvider, useAuth } from "./hooks/useAuth";
|
||||||
|
import CharacterDetail from "./pages/CharacterDetail";
|
||||||
|
import CharactersList from "./pages/CharactersList";
|
||||||
|
import ForgotPassword from "./pages/ForgotPassword";
|
||||||
|
import GameSession from "./pages/GameSession";
|
||||||
|
import GamesList from "./pages/GamesList";
|
||||||
|
import Login from "./pages/Login";
|
||||||
|
import Register from "./pages/Register";
|
||||||
|
import ResetPassword from "./pages/ResetPassword";
|
||||||
|
|
||||||
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
|
const { user, loading } = useAuth();
|
||||||
|
if (loading) return <p className="p-8 text-slate-400">Lädt…</p>;
|
||||||
|
if (!user) return <Navigate to="/login" replace />;
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppRoutes() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Navbar />
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/register" element={<Register />} />
|
||||||
|
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||||
|
<Route path="/reset-password" element={<ResetPassword />} />
|
||||||
|
<Route
|
||||||
|
path="/games"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<GamesList />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/games/:gameId"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<GameSession />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/characters"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<CharactersList />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/characters/:characterId"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<CharacterDetail />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="*" element={<Navigate to="/games" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<AppRoutes />
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}` : ""}`,
|
||||||
|
);
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
import { Character } from "../api/characters";
|
||||||
|
|
||||||
|
export default function CharacterCard({ character }: { character: Character }) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={`/characters/${character.id}`}
|
||||||
|
className="block rounded-lg border border-slate-800 bg-slate-900 p-4 hover:border-amber-500"
|
||||||
|
>
|
||||||
|
<h2 className="text-lg font-semibold text-slate-100">{character.name}</h2>
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
{character.race ?? "?"} · {character.char_class ?? "?"} · Stufe {character.level}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
import { Game } from "../api/games";
|
||||||
|
|
||||||
|
export default function GameCard({ game }: { game: Game }) {
|
||||||
|
const created = new Date(game.created_at).toLocaleString("de-DE");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={`/games/${game.id}`}
|
||||||
|
className="block rounded-lg border border-slate-800 bg-slate-900 p-4 hover:border-amber-500"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-100">{game.name}</h2>
|
||||||
|
{game.participation_code && (
|
||||||
|
<span className="shrink-0 rounded bg-amber-500/20 px-2 py-1 font-mono text-xs text-amber-300">
|
||||||
|
Code: {game.participation_code}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-slate-400">{game.description}</p>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-500">
|
||||||
|
<span>Erstellt: {created}</span>
|
||||||
|
<span>Von: {game.creator_name}</span>
|
||||||
|
<span>
|
||||||
|
{game.player_count} Spieler
|
||||||
|
{game.player_names.length > 0 && `: ${game.player_names.join(", ")}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import { useAuth } from "../hooks/useAuth";
|
||||||
|
|
||||||
|
export default function Navbar() {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
logout();
|
||||||
|
navigate("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="flex items-center justify-between border-b border-slate-800 bg-slate-900 px-4 py-3">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Link to="/games" className="font-semibold text-slate-100 hover:text-amber-400">
|
||||||
|
Spiele
|
||||||
|
</Link>
|
||||||
|
<Link to="/characters" className="font-semibold text-slate-100 hover:text-amber-400">
|
||||||
|
Charaktere
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-sm text-slate-400">
|
||||||
|
<span>{user.name}</span>
|
||||||
|
<button onClick={handleLogout} className="rounded bg-slate-800 px-3 py-1 hover:bg-slate-700">
|
||||||
|
Abmelden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export default function SendMessageForm({
|
||||||
|
onSend,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
onSend: (content: string) => Promise<void>;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
const [content, setContent] = useState("");
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
|
||||||
|
async function handleSend() {
|
||||||
|
const trimmed = content.trim();
|
||||||
|
if (!trimmed || sending) return;
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
await onSend(trimmed);
|
||||||
|
setContent("");
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2 border-t border-slate-800 p-3">
|
||||||
|
<textarea
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
disabled={disabled || sending}
|
||||||
|
placeholder="Was tust du?"
|
||||||
|
rows={2}
|
||||||
|
className="flex-1 resize-none rounded bg-slate-800 px-3 py-2 outline-none focus:ring-2 focus:ring-amber-400 disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={disabled || sending || !content.trim()}
|
||||||
|
className="shrink-0 self-end rounded bg-amber-500 px-4 py-2 font-semibold text-slate-950 hover:bg-amber-400 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{sending ? "…" : "Senden"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
import { Message } from "../api/games";
|
||||||
|
|
||||||
|
function senderLabel(message: Message): string {
|
||||||
|
if (message.sender_type === "dm") return "Dungeon Master";
|
||||||
|
if (message.sender_type === "system") return "System";
|
||||||
|
if (message.character_name) return `${message.player_name} (${message.character_name})`;
|
||||||
|
return message.player_name ?? "Spieler";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TranscriptPane({ messages }: { messages: Message[] }) {
|
||||||
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
bottomRef.current?.scrollIntoView({ block: "end" });
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col gap-3 overflow-y-auto p-4">
|
||||||
|
{messages.map((m) => (
|
||||||
|
<div key={m.id} className={m.sender_type === "system" ? "text-red-400 italic" : m.sender_type === "dm" ? "text-slate-100" : "text-slate-300"}>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
m.sender_type === "dm"
|
||||||
|
? "font-semibold text-amber-400"
|
||||||
|
: m.sender_type === "system"
|
||||||
|
? "font-semibold text-red-400"
|
||||||
|
: "font-semibold text-sky-400"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{senderLabel(m)}:
|
||||||
|
</span>{" "}
|
||||||
|
<span className="whitespace-pre-wrap">{m.content}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { createContext, ReactNode, useContext, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import * as authApi from "../api/auth";
|
||||||
|
import { clearToken, getToken } from "../api/client";
|
||||||
|
|
||||||
|
interface AuthContextValue {
|
||||||
|
user: authApi.User | null;
|
||||||
|
loading: boolean;
|
||||||
|
login: (email: string, password: string) => Promise<void>;
|
||||||
|
register: (email: string, password: string, name: string) => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [user, setUser] = useState<authApi.User | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
if (!getToken()) {
|
||||||
|
setUser(null);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const me = await authApi.getMe();
|
||||||
|
setUser(me);
|
||||||
|
} catch {
|
||||||
|
clearToken();
|
||||||
|
setUser(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function login(email: string, password: string) {
|
||||||
|
await authApi.login(email, password);
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function register(email: string, password: string, name: string) {
|
||||||
|
await authApi.register(email, password, name);
|
||||||
|
await login(email, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
clearToken();
|
||||||
|
setUser(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ user, loading, login, register, logout, refresh }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth(): AuthContextValue {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
import { apiFetch } from "../api/client";
|
||||||
|
import { Message } from "../api/games";
|
||||||
|
|
||||||
|
export function useGameSocket(
|
||||||
|
gameId: string | undefined,
|
||||||
|
onMessage: (message: Message) => void,
|
||||||
|
onError: (detail: string) => void,
|
||||||
|
) {
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
const onMessageRef = useRef(onMessage);
|
||||||
|
onMessageRef.current = onMessage;
|
||||||
|
const onErrorRef = useRef(onError);
|
||||||
|
onErrorRef.current = onError;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!gameId) return;
|
||||||
|
let cancelled = false;
|
||||||
|
let intentionalClose = false;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const { ticket } = await apiFetch<{ ticket: string }>(`/api/games/${gameId}/ws-ticket`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
||||||
|
const ws = new WebSocket(`${protocol}://${window.location.host}/ws/games/${gameId}?ticket=${ticket}`);
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
if (data.type === "message") onMessageRef.current(data.message);
|
||||||
|
else if (data.type === "error") onErrorRef.current(data.detail);
|
||||||
|
};
|
||||||
|
ws.onclose = () => {
|
||||||
|
if (!intentionalClose) onErrorRef.current("connection_lost");
|
||||||
|
};
|
||||||
|
wsRef.current = ws;
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
intentionalClose = true;
|
||||||
|
wsRef.current?.close();
|
||||||
|
wsRef.current = null;
|
||||||
|
};
|
||||||
|
}, [gameId]);
|
||||||
|
|
||||||
|
function send(content: string) {
|
||||||
|
if (wsRef.current?.readyState !== WebSocket.OPEN) {
|
||||||
|
onErrorRef.current("connection_lost");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wsRef.current.send(JSON.stringify({ type: "message", content }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { send };
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
|
||||||
|
import App from "./App";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
host: true,
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
"/api": "http://localhost:8000",
|
||||||
|
"/auth": "http://localhost:8000",
|
||||||
|
"/users": "http://localhost:8000",
|
||||||
|
"/ws": {
|
||||||
|
target: "ws://localhost:8000",
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user