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
+81
View File
@@ -0,0 +1,81 @@
import json
import logging
import uuid
from sqlalchemy.ext.asyncio import AsyncSession
logger = logging.getLogger("app.llm.orchestrator")
from app.config import settings
from app.llm.client import get_dm_system_prompt, get_llm_client
from app.llm.context import build_context
from app.llm.tools import character_sheet, dice
from app.models.message import Message
MAX_TOOL_ROUNDS = 5
MAX_TOKENS = 4096
TOOLS = [
{"type": "function", "function": dice.TOOL_SCHEMA},
{"type": "function", "function": character_sheet.TOOL_SCHEMA},
]
async def _execute_tool_call(
session: AsyncSession, game_id: uuid.UUID, tool_name: str, tool_input: dict
) -> dict:
"""Runs one tool call. Errors are returned as a payload (not raised), so the DM sees them
as a tool result and can recover instead of the whole turn crashing."""
try:
if tool_name == "roll_dice":
return dice.roll(tool_input["notation"])
if tool_name == "upsert_character_sheet":
return await character_sheet.upsert(session, game_id, tool_input)
return {"error": f"Unknown tool {tool_name!r}"}
except Exception as exc: # noqa: BLE001
logger.warning("Tool call %s failed: %s", tool_name, exc)
await session.rollback()
return {"error": str(exc)}
async def run_dm_turn(session: AsyncSession, game_id: uuid.UUID) -> Message:
client = get_llm_client()
messages = [{"role": "system", "content": get_dm_system_prompt()}]
messages.extend(await build_context(session, game_id))
final_text = ""
for _ in range(MAX_TOOL_ROUNDS):
response = await client.chat.completions.create(
model=settings.dm_model,
max_tokens=MAX_TOKENS,
messages=messages,
tools=TOOLS,
)
choice = response.choices[0]
message = choice.message
final_text = message.content or ""
if not message.tool_calls:
break
messages.append(message.model_dump(exclude_unset=True))
for tool_call in message.tool_calls:
tool_input = json.loads(tool_call.function.arguments)
result = await _execute_tool_call(session, game_id, tool_call.function.name, tool_input)
messages.append(
{
"role": "tool",
"tool_call_id": tool_call.id,
"content": json.dumps(result),
}
)
else:
if not final_text:
final_text = "(Der Dungeon Master braucht einen Moment länger als erwartet — bitte versuche es erneut.)"
dm_message = Message(game_id=game_id, sender_type="dm", content=final_text)
session.add(dm_message)
await session.commit()
await session.refresh(dm_message)
return dm_message