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,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}
|
||||
Reference in New Issue
Block a user