07665595a2
The adventure-text field was buried at the bottom of the proposal form, after the whole interview — the user wanted it to be the very first thing shown when clicking "Neues Spiel", since pasting an adventure should drive the proposal rather than follow it. GameSetupChat now opens on an "Eigenes Abenteuer (optional)" step: paste the text (or skip) before the wizard chat starts at all. The setup-chat backend call now receives that text and, when present, infers genre/world/scope from its opening directly instead of asking about them — the proposed name/description come out already specific to the pasted adventure. Removed the now-redundant adventure textarea from the proposal form (it's collected once, upfront). Verified live: pasting a short pirate one-shot produced "Klingt nach einem klassischen Piraten-One-Shot rund um Port Royal – das Genre und den Umfang leite ich direkt daraus ab", skipping straight to the one remaining question (experience level), and the final proposal named Isabella Steel and Blackfin's fortress directly from the text. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
271 lines
9.3 KiB
Python
271 lines
9.3 KiB
Python
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.llm.game_setup import run_game_setup_turn
|
|
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.rag.adventure_ingestion import ingest_adventure_text
|
|
from app.schemas.game import GameCreate, GameJoin, GameRead
|
|
from app.schemas.game_setup import GameSetupChatRequest, GameSetupChatResponse
|
|
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),
|
|
status=game.status,
|
|
ended_reason=game.ended_reason,
|
|
has_adventure=bool(game.adventure_text.strip()),
|
|
)
|
|
)
|
|
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,
|
|
adventure_text=payload.adventure_text,
|
|
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)
|
|
|
|
if payload.adventure_text.strip():
|
|
await ingest_adventure_text(session, game.id, payload.adventure_text)
|
|
|
|
serialized = await _serialize_games(session, [game], user.id)
|
|
return serialized[0]
|
|
|
|
|
|
@router.post("/setup-chat", response_model=GameSetupChatResponse)
|
|
async def game_setup_chat(
|
|
payload: GameSetupChatRequest,
|
|
user: User = Depends(current_active_user),
|
|
) -> GameSetupChatResponse:
|
|
result = await run_game_setup_turn(payload.messages, payload.adventure_text)
|
|
return GameSetupChatResponse(**result)
|
|
|
|
|
|
@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)}
|