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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Thorsten
2026-08-31 18:43:08 +02:00
commit f37dc9fa76
75 changed files with 106988 additions and 0 deletions
View File
+42
View File
@@ -0,0 +1,42 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.users import current_active_user
from app.db import get_async_session
from app.models.character import Character
from app.models.user import User
from app.schemas.character import CharacterRead
router = APIRouter(prefix="/api/characters", tags=["characters"])
@router.get("", response_model=list[CharacterRead])
async def list_my_characters(
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
) -> list[Character]:
characters = (
await session.execute(
select(Character)
.where(Character.owner_id == user.id)
.order_by(Character.created_at.desc())
)
).scalars().all()
return list(characters)
@router.get("/{character_id}", response_model=CharacterRead)
async def get_character(
character_id: uuid.UUID,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
) -> Character:
character = await session.get(Character, character_id)
if character is None:
raise HTTPException(status_code=404, detail="Character not found")
if character.owner_id != user.id:
raise HTTPException(status_code=403, detail="Not your character")
return character
+251
View File
@@ -0,0 +1,251 @@
import secrets
import string
import uuid
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.users import current_active_user
from app.db import get_async_session
from app.models.character import Character
from app.models.game import Game, GameParticipant
from app.models.message import Message
from app.models.user import User
from app.schemas.game import GameCreate, GameJoin, GameRead
from app.schemas.message import MessageRead
from app.ws_tickets import issue_ticket
router = APIRouter(prefix="/api/games", tags=["games"])
CODE_ALPHABET = string.ascii_uppercase + string.digits
def _generate_participation_code() -> str:
return "".join(secrets.choice(CODE_ALPHABET) for _ in range(8))
async def _serialize_games(
session: AsyncSession, games: list[Game], viewer_id: uuid.UUID
) -> list[GameRead]:
if not games:
return []
game_ids = [g.id for g in games]
creators = (
await session.execute(select(User.id, User.name).where(User.id.in_([g.creator_id for g in games])))
).all()
creator_names = {row.id: row.name for row in creators}
participant_rows = (
await session.execute(
select(GameParticipant.game_id, GameParticipant.user_id, GameParticipant.character_id, User.name)
.join(User, User.id == GameParticipant.user_id)
.where(GameParticipant.game_id.in_(game_ids))
)
).all()
participants_by_game: dict[uuid.UUID, list[str]] = {}
viewer_participation: dict[uuid.UUID, uuid.UUID | None] = {}
for game_id, participant_user_id, character_id, name in participant_rows:
participants_by_game.setdefault(game_id, []).append(name)
if participant_user_id == viewer_id:
viewer_participation[game_id] = character_id
result = []
for game in games:
names = participants_by_game.get(game.id, [])
is_participant = game.id in viewer_participation
result.append(
GameRead(
id=game.id,
name=game.name,
description=game.description,
creator_id=game.creator_id,
creator_name=creator_names.get(game.creator_id, "?"),
player_count=len(names),
player_names=names,
participation_code=game.participation_code if game.creator_id == viewer_id else None,
created_at=game.created_at,
is_participant=is_participant,
my_character_id=viewer_participation.get(game.id),
)
)
return result
@router.get("", response_model=list[GameRead])
async def list_games(
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
) -> list[GameRead]:
games = (
(await session.execute(select(Game).order_by(Game.created_at.desc()))).scalars().all()
)
return await _serialize_games(session, list(games), user.id)
@router.post("", response_model=GameRead)
async def create_game(
payload: GameCreate,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
) -> GameRead:
game = Game(
name=payload.name,
description=payload.description,
creator_id=user.id,
participation_code=_generate_participation_code(),
)
session.add(game)
await session.flush()
session.add(GameParticipant(game_id=game.id, user_id=user.id))
await session.commit()
await session.refresh(game)
serialized = await _serialize_games(session, [game], user.id)
return serialized[0]
@router.get("/{game_id}", response_model=GameRead)
async def get_game(
game_id: uuid.UUID,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
) -> GameRead:
game = await session.get(Game, game_id)
if game is None:
raise HTTPException(status_code=404, detail="Game not found")
serialized = await _serialize_games(session, [game], user.id)
return serialized[0]
async def _serialize_messages(session: AsyncSession, messages: list[Message]) -> list[MessageRead]:
if not messages:
return []
user_ids = {m.user_id for m in messages if m.user_id is not None}
character_ids = {m.character_id for m in messages if m.character_id is not None}
names: dict[uuid.UUID, str] = {}
if user_ids:
rows = (await session.execute(select(User.id, User.name).where(User.id.in_(user_ids)))).all()
names.update({row.id: row.name for row in rows})
char_names: dict[uuid.UUID, str] = {}
if character_ids:
rows = (
await session.execute(select(Character.id, Character.name).where(Character.id.in_(character_ids)))
).all()
char_names.update({row.id: row.name for row in rows})
return [
MessageRead(
id=m.id,
sender_type=m.sender_type,
user_id=m.user_id,
player_name=names.get(m.user_id) if m.user_id else None,
character_id=m.character_id,
character_name=char_names.get(m.character_id) if m.character_id else None,
content=m.content,
created_at=m.created_at,
)
for m in messages
]
async def _require_participant(session: AsyncSession, game_id: uuid.UUID, user_id: uuid.UUID) -> GameParticipant:
participant = (
await session.execute(
select(GameParticipant).where(
GameParticipant.game_id == game_id, GameParticipant.user_id == user_id
)
)
).scalar_one_or_none()
if participant is None:
raise HTTPException(status_code=403, detail="You have not joined this game")
return participant
@router.get("/{game_id}/messages", response_model=list[MessageRead])
async def get_recent_messages(
game_id: uuid.UUID,
limit: int = 5,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
) -> list[MessageRead]:
await _require_participant(session, game_id, user.id)
rows = (
await session.execute(
select(Message)
.where(Message.game_id == game_id)
.order_by(Message.created_at.desc(), Message.id.desc())
.limit(limit)
)
).scalars().all()
ordered = list(reversed(rows))
return await _serialize_messages(session, ordered)
@router.get("/{game_id}/messages/full", response_model=list[MessageRead])
async def get_full_messages(
game_id: uuid.UUID,
before_id: int | None = None,
limit: int = 100,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
) -> list[MessageRead]:
await _require_participant(session, game_id, user.id)
query = select(Message).where(Message.game_id == game_id)
if before_id is not None:
query = query.where(Message.id < before_id)
query = query.order_by(Message.id.desc()).limit(limit)
rows = (await session.execute(query)).scalars().all()
ordered = list(reversed(rows))
return await _serialize_messages(session, ordered)
@router.post("/{game_id}/join", response_model=GameRead)
async def join_game(
game_id: uuid.UUID,
payload: GameJoin,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
) -> GameRead:
game = await session.get(Game, game_id)
if game is None:
raise HTTPException(status_code=404, detail="Game not found")
if not secrets.compare_digest(payload.participation_code, game.participation_code):
raise HTTPException(status_code=403, detail="Invalid participation code")
existing = (
await session.execute(
select(GameParticipant).where(
GameParticipant.game_id == game_id, GameParticipant.user_id == user.id
)
)
).scalar_one_or_none()
if existing is None:
session.add(
GameParticipant(game_id=game_id, user_id=user.id, character_id=payload.character_id)
)
elif payload.character_id is not None:
existing.character_id = payload.character_id
await session.commit()
serialized = await _serialize_games(session, [game], user.id)
return serialized[0]
@router.post("/{game_id}/ws-ticket")
async def create_ws_ticket(
game_id: uuid.UUID,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
) -> dict[str, str]:
await _require_participant(session, game_id, user.id)
return {"ticket": issue_ticket(game_id, user.id)}
+136
View File
@@ -0,0 +1,136 @@
import asyncio
import logging
import uuid
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from sqlalchemy import select
from app.db import async_session_maker
from app.llm.orchestrator import run_dm_turn
from app.models.game import GameParticipant
from app.models.message import Message
from app.ws_tickets import consume_ticket
logger = logging.getLogger("app.ws_game")
router = APIRouter()
class ConnectionManager:
def __init__(self) -> None:
self._rooms: dict[uuid.UUID, set[WebSocket]] = {}
self._locks: dict[uuid.UUID, asyncio.Lock] = {}
def connect(self, game_id: uuid.UUID, websocket: WebSocket) -> None:
self._rooms.setdefault(game_id, set()).add(websocket)
def disconnect(self, game_id: uuid.UUID, websocket: WebSocket) -> None:
room = self._rooms.get(game_id)
if room is not None:
room.discard(websocket)
if not room:
self._rooms.pop(game_id, None)
async def broadcast(self, game_id: uuid.UUID, payload: dict) -> None:
for ws in list(self._rooms.get(game_id, ())):
try:
await ws.send_json(payload)
except Exception: # noqa: BLE001 — a dead socket shouldn't break the broadcast
self.disconnect(game_id, ws)
def get_lock(self, game_id: uuid.UUID) -> asyncio.Lock:
return self._locks.setdefault(game_id, asyncio.Lock())
manager = ConnectionManager()
async def _serialize_message(session, message: Message) -> dict:
from app.models.character import Character
from app.models.user import User
player_name = None
character_name = None
if message.user_id is not None:
user = await session.get(User, message.user_id)
player_name = user.name if user else None
if message.character_id is not None:
character = await session.get(Character, message.character_id)
character_name = character.name if character else None
return {
"id": message.id,
"sender_type": message.sender_type,
"user_id": str(message.user_id) if message.user_id else None,
"player_name": player_name,
"character_id": str(message.character_id) if message.character_id else None,
"character_name": character_name,
"content": message.content,
"created_at": message.created_at.isoformat(),
}
@router.websocket("/ws/games/{game_id}")
async def game_websocket(websocket: WebSocket, game_id: uuid.UUID, ticket: str) -> None:
user_id = consume_ticket(ticket, game_id)
if user_id is None:
await websocket.close(code=4401)
return
await websocket.accept()
manager.connect(game_id, websocket)
try:
while True:
data = await websocket.receive_json()
if data.get("type") != "message":
continue
content = (data.get("content") or "").strip()
if not content:
continue
async with manager.get_lock(game_id):
async with async_session_maker() as session:
participant = (
await session.execute(
select(GameParticipant).where(
GameParticipant.game_id == game_id,
GameParticipant.user_id == user_id,
)
)
).scalar_one_or_none()
if participant is None:
await websocket.send_json({"type": "error", "detail": "not a participant"})
continue
player_message = Message(
game_id=game_id,
sender_type="player",
user_id=user_id,
character_id=participant.character_id,
content=content,
)
session.add(player_message)
await session.commit()
await session.refresh(player_message)
await manager.broadcast(
game_id,
{"type": "message", "message": await _serialize_message(session, player_message)},
)
try:
dm_message = await run_dm_turn(session, game_id)
except Exception: # noqa: BLE001
logger.exception("DM turn failed for game %s", game_id)
await websocket.send_json({"type": "error", "detail": "dm_turn_failed"})
continue
await manager.broadcast(
game_id,
{"type": "message", "message": await _serialize_message(session, dm_message)},
)
except WebSocketDisconnect:
pass
finally:
manager.disconnect(game_id, websocket)