Compare commits
6 Commits
fb19795fdb
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c374f2bc2 | |||
| f9b9785857 | |||
| 1b64010280 | |||
| d62970ee1b | |||
| cc680dc392 | |||
| f5c62d27fd |
+1
-1
@@ -5,7 +5,7 @@ LLM_PROVIDER=xai
|
||||
|
||||
# x.ai API (Grok) — used when LLM_PROVIDER=xai. Get a key at https://console.x.ai
|
||||
XAI_API_KEY=
|
||||
DM_MODEL=grok-4.6
|
||||
DM_MODEL=grok-4.3
|
||||
|
||||
# LM Studio local server — used when LLM_PROVIDER=lmstudio. Use host.docker.internal, not a
|
||||
# LAN IP — the backend runs inside Docker and can't necessarily reach the host's LAN address
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""add self_rolls and pending_roll to game_participants
|
||||
|
||||
Revision ID: a3c7e9f1b204
|
||||
Revises: f1a2b3c4d5e6
|
||||
Create Date: 2026-09-03 09:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'a3c7e9f1b204'
|
||||
down_revision: Union[str, None] = 'f1a2b3c4d5e6'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
'game_participants',
|
||||
sa.Column('self_rolls', sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
)
|
||||
op.add_column(
|
||||
'game_participants',
|
||||
sa.Column('pending_roll', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('game_participants', 'pending_roll')
|
||||
op.drop_column('game_participants', 'self_rolls')
|
||||
@@ -14,7 +14,7 @@ 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 import GameCreate, GameJoin, GameRead, SelfRollsUpdate
|
||||
from app.schemas.game_setup import GameSetupChatRequest, GameSetupChatResponse
|
||||
from app.schemas.message import MessageRead
|
||||
from app.ws_tickets import issue_ticket
|
||||
@@ -43,17 +43,28 @@ async def _serialize_games(
|
||||
|
||||
participant_rows = (
|
||||
await session.execute(
|
||||
select(GameParticipant.game_id, GameParticipant.user_id, GameParticipant.character_id, User.name)
|
||||
select(
|
||||
GameParticipant.game_id,
|
||||
GameParticipant.user_id,
|
||||
GameParticipant.character_id,
|
||||
GameParticipant.self_rolls,
|
||||
GameParticipant.pending_roll,
|
||||
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:
|
||||
viewer_self_rolls: dict[uuid.UUID, bool] = {}
|
||||
viewer_pending_roll: dict[uuid.UUID, dict | None] = {}
|
||||
for game_id, participant_user_id, character_id, self_rolls, pending_roll, name in participant_rows:
|
||||
participants_by_game.setdefault(game_id, []).append(name)
|
||||
if participant_user_id == viewer_id:
|
||||
viewer_participation[game_id] = character_id
|
||||
viewer_self_rolls[game_id] = self_rolls
|
||||
viewer_pending_roll[game_id] = pending_roll
|
||||
|
||||
result = []
|
||||
for game in games:
|
||||
@@ -75,6 +86,8 @@ async def _serialize_games(
|
||||
status=game.status,
|
||||
ended_reason=game.ended_reason,
|
||||
has_adventure=bool(game.adventure_text.strip()),
|
||||
my_self_rolls=viewer_self_rolls.get(game.id, False),
|
||||
my_pending_roll=viewer_pending_roll.get(game.id),
|
||||
)
|
||||
)
|
||||
return result
|
||||
@@ -277,6 +290,24 @@ async def join_game(
|
||||
return serialized[0]
|
||||
|
||||
|
||||
@router.patch("/{game_id}/self-rolls", response_model=GameRead)
|
||||
async def update_self_rolls(
|
||||
game_id: uuid.UUID,
|
||||
payload: SelfRollsUpdate,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
) -> GameRead:
|
||||
participant = await _require_participant(session, game_id, user.id)
|
||||
participant.self_rolls = payload.self_rolls
|
||||
if not payload.self_rolls:
|
||||
participant.pending_roll = None
|
||||
await session.commit()
|
||||
|
||||
game = await session.get(Game, game_id)
|
||||
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,
|
||||
|
||||
@@ -74,13 +74,16 @@ async def _serialize_message(session, message: Message) -> dict:
|
||||
|
||||
|
||||
async def _run_and_broadcast_dm_turn(
|
||||
session: AsyncSession, game_id: uuid.UUID, latest_player_message: str
|
||||
session: AsyncSession, game_id: uuid.UUID, latest_player_message: str, user_id: uuid.UUID | None = None
|
||||
) -> None:
|
||||
await manager.broadcast(game_id, {"type": "typing"})
|
||||
|
||||
async def _broadcast_roll(notation: str) -> None:
|
||||
await manager.broadcast(game_id, {"type": "rolling", "notation": notation})
|
||||
|
||||
async def _broadcast_awaiting_roll(notation: str, reason: str) -> None:
|
||||
await manager.broadcast(game_id, {"type": "awaiting_roll", "notation": notation, "reason": reason})
|
||||
|
||||
async def _broadcast_game_ended(reason: str) -> None:
|
||||
await manager.broadcast(game_id, {"type": "game_ended", "reason": reason})
|
||||
|
||||
@@ -89,7 +92,9 @@ async def _run_and_broadcast_dm_turn(
|
||||
session,
|
||||
game_id,
|
||||
latest_player_message=latest_player_message,
|
||||
user_id=user_id,
|
||||
on_roll=_broadcast_roll,
|
||||
on_awaiting_roll=_broadcast_awaiting_roll,
|
||||
on_game_ended=_broadcast_game_ended,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
@@ -123,7 +128,7 @@ async def game_websocket(websocket: WebSocket, game_id: uuid.UUID, ticket: str)
|
||||
await session.execute(select(Message.id).where(Message.game_id == game_id).limit(1))
|
||||
).first() is not None
|
||||
if not has_messages:
|
||||
await _run_and_broadcast_dm_turn(session, game_id, KICKOFF_QUERY)
|
||||
await _run_and_broadcast_dm_turn(session, game_id, KICKOFF_QUERY, user_id=user_id)
|
||||
|
||||
try:
|
||||
while True:
|
||||
@@ -164,7 +169,7 @@ async def game_websocket(websocket: WebSocket, game_id: uuid.UUID, ticket: str)
|
||||
{"type": "message", "message": await _serialize_message(session, player_message)},
|
||||
)
|
||||
|
||||
await _run_and_broadcast_dm_turn(session, game_id, content)
|
||||
await _run_and_broadcast_dm_turn(session, game_id, content, user_id=user_id)
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
|
||||
@@ -18,6 +18,11 @@ def get_game_setup_prompt() -> str:
|
||||
return (PROMPTS_DIR / "game_setup_prompt.txt").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_adventure_outline_prompt() -> str:
|
||||
return (PROMPTS_DIR / "adventure_outline_prompt.txt").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_llm_client() -> AsyncOpenAI:
|
||||
if settings.llm_provider == "lmstudio":
|
||||
|
||||
@@ -1,24 +1,63 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from app.config import settings
|
||||
from app.llm.client import get_game_setup_prompt, get_llm_client
|
||||
from app.llm.client import get_adventure_outline_prompt, get_game_setup_prompt, get_llm_client
|
||||
from app.llm.json_utils import fix_double_escaped_unicode
|
||||
from app.llm.tools import game_setup as game_setup_tool
|
||||
|
||||
logger = logging.getLogger("app.llm.game_setup")
|
||||
|
||||
MAX_ROUNDS = 3
|
||||
MAX_TOKENS = 1024
|
||||
# Only the opening of a pasted adventure is needed here to infer genre/world/scope for the
|
||||
# proposal — this is a cheap interview call, not the RAG pipeline that handles the full text.
|
||||
ADVENTURE_EXCERPT_CHARS = 3000
|
||||
# The outline is a free-text document of several thousand words, unlike the short
|
||||
# name+description proposal above, so it needs a much larger budget.
|
||||
OUTLINE_MAX_TOKENS = 4096
|
||||
|
||||
TOOLS = [{"type": "function", "function": game_setup_tool.TOOL_SCHEMA}]
|
||||
|
||||
|
||||
async def _generate_adventure_outline(client, messages: list[dict], proposal: dict) -> str | None:
|
||||
"""One-shot follow-up call: draft a full adventure outline from the interview so far.
|
||||
|
||||
Only called when the player didn't paste their own adventure text. Best-effort — any
|
||||
failure just means the game falls back to today's fully-improvised DM, never blocks
|
||||
game creation.
|
||||
"""
|
||||
outline_messages = [
|
||||
{"role": "system", "content": get_adventure_outline_prompt()},
|
||||
*messages[1:],
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
f"Vorgeschlagener Spielname: {proposal.get('name', '')}\n"
|
||||
f"Kurzbeschreibung: {proposal.get('description', '')}\n\n"
|
||||
"Entwirf jetzt das vollständige Abenteuer-Outline wie beschrieben."
|
||||
),
|
||||
},
|
||||
]
|
||||
try:
|
||||
response = await client.chat.completions.create(
|
||||
model=settings.dm_model,
|
||||
max_tokens=OUTLINE_MAX_TOKENS,
|
||||
messages=outline_messages,
|
||||
)
|
||||
content = response.choices[0].message.content
|
||||
return content.strip() if content and content.strip() else None
|
||||
except Exception:
|
||||
logger.exception("Adventure outline generation failed")
|
||||
return None
|
||||
|
||||
|
||||
async def run_game_setup_turn(client_messages: list[dict], adventure_text: str = "") -> dict:
|
||||
"""Stateless setup-wizard turn: the caller owns conversation history (no DB, no game yet).
|
||||
|
||||
Returns {"messages": <history to resend next turn>, "assistant_text": str | None,
|
||||
"proposal": {"name": str, "description": str} | None}.
|
||||
"proposal": {"name": str, "description": str} | None,
|
||||
"generated_adventure": str | None}.
|
||||
"""
|
||||
client = get_llm_client()
|
||||
system_prompt = get_game_setup_prompt()
|
||||
@@ -64,4 +103,13 @@ async def run_game_setup_turn(client_messages: list[dict], adventure_text: str =
|
||||
if proposal:
|
||||
break
|
||||
|
||||
return {"messages": messages[1:], "assistant_text": assistant_text, "proposal": proposal}
|
||||
generated_adventure: str | None = None
|
||||
if proposal and not adventure_text.strip():
|
||||
generated_adventure = await _generate_adventure_outline(client, messages, proposal)
|
||||
|
||||
return {
|
||||
"messages": messages[1:],
|
||||
"assistant_text": assistant_text,
|
||||
"proposal": proposal,
|
||||
"generated_adventure": generated_adventure,
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ 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.json_utils import fix_double_escaped_unicode
|
||||
from app.llm.tools import character_sheet, dice, end_game, monster, world_state
|
||||
from app.llm.tools import character_sheet, dice, end_game, link_existing_character, monster, world_state
|
||||
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
|
||||
@@ -28,6 +29,7 @@ TOOLS = [
|
||||
{"type": "function", "function": end_game.TOOL_SCHEMA},
|
||||
{"type": "function", "function": monster.TOOL_SCHEMA},
|
||||
{"type": "function", "function": world_state.TOOL_SCHEMA},
|
||||
{"type": "function", "function": link_existing_character.TOOL_SCHEMA},
|
||||
]
|
||||
|
||||
|
||||
@@ -47,6 +49,8 @@ async def _execute_tool_call(
|
||||
return await monster.update(session, game_id, tool_input)
|
||||
if tool_name == "update_world_state":
|
||||
return await world_state.update(session, game_id, tool_input)
|
||||
if tool_name == "link_existing_character":
|
||||
return await link_existing_character.link(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)
|
||||
@@ -54,6 +58,57 @@ async def _execute_tool_call(
|
||||
return {"error": str(exc)}
|
||||
|
||||
|
||||
async def _request_player_roll(
|
||||
session: AsyncSession, participant: GameParticipant, notation: str, reason: str
|
||||
) -> dict:
|
||||
"""Handles a roll_dice call for a player who rolls their own physical dice: validates the
|
||||
notation without touching the RNG, records it as the participant's pending roll (picked up
|
||||
and cleared at the top of their next turn), and returns a tool result that tells the DM to
|
||||
ask for it and wait instead of rolling itself."""
|
||||
try:
|
||||
dice.parse_notation(notation)
|
||||
except ValueError as exc:
|
||||
return {"error": str(exc)}
|
||||
|
||||
participant.pending_roll = {"notation": notation, "reason": reason}
|
||||
await session.commit()
|
||||
|
||||
return {
|
||||
"awaiting_player_roll": True,
|
||||
"notation": notation,
|
||||
"reason": reason,
|
||||
"note": (
|
||||
f"Der Spieler würfelt selbst mit physischen Würfeln. Nenne ihm klar und knapp, was er "
|
||||
f"würfeln soll ({notation}" + (f", {reason}" if reason else "") + "), und warte auf "
|
||||
"seine Antwort mit dem Ergebnis in einer künftigen Nachricht — würfle nicht selbst und "
|
||||
"erfinde kein Ergebnis."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _format_character_sheet(character: Character) -> str:
|
||||
parts = [
|
||||
f"- {character.name} ({character.race or '?'} {character.char_class or '?'}, Stufe {character.level})"
|
||||
]
|
||||
if character.stats:
|
||||
parts.append(f" Werte: {character.stats}")
|
||||
hp = None
|
||||
if character.current_hp is not None or character.max_hp is not None:
|
||||
hp = f"{character.current_hp if character.current_hp is not None else '?'}/{character.max_hp if character.max_hp is not None else '?'}"
|
||||
if hp:
|
||||
parts.append(f" TP: {hp}")
|
||||
if character.combat_stats:
|
||||
parts.append(f" Kampfwerte: {character.combat_stats}")
|
||||
if character.abilities:
|
||||
ability_names = ", ".join(a.get("name", "") for a in character.abilities if isinstance(a, dict))
|
||||
parts.append(f" Fähigkeiten: {ability_names}")
|
||||
if character.equipment:
|
||||
parts.append(f" Ausrüstung: {', '.join(character.equipment)}")
|
||||
if character.description:
|
||||
parts.append(f" Hintergrund: {character.description}")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
async def _check_hp_game_over(session: AsyncSession, game_id: uuid.UUID, result: dict) -> str | None:
|
||||
"""After an upsert_character_sheet call, auto-ends the game if the character it touched
|
||||
dropped to 0 HP or below. Returns the end reason if the game was just ended, else None."""
|
||||
@@ -76,12 +131,38 @@ async def run_dm_turn(
|
||||
session: AsyncSession,
|
||||
game_id: uuid.UUID,
|
||||
latest_player_message: str | None = None,
|
||||
user_id: uuid.UUID | None = None,
|
||||
on_roll: Callable[[str], Awaitable[None]] | None = None,
|
||||
on_awaiting_roll: Callable[[str, str], Awaitable[None]] | None = None,
|
||||
on_game_ended: Callable[[str], Awaitable[None]] | None = None,
|
||||
) -> Message:
|
||||
client = get_llm_client()
|
||||
system_prompt = get_dm_system_prompt()
|
||||
|
||||
participant: GameParticipant | None = None
|
||||
if user_id is not None:
|
||||
participant = (
|
||||
await session.execute(
|
||||
select(GameParticipant).where(
|
||||
GameParticipant.game_id == game_id, GameParticipant.user_id == user_id
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if participant is not None and participant.pending_roll:
|
||||
pending = participant.pending_roll
|
||||
system_prompt += (
|
||||
f"\n\nOffene Würfelanfrage: Du hast {pending.get('notation')} "
|
||||
f"({pending.get('reason') or 'ohne Angabe'}) angefragt, der Spieler würfelt selbst. "
|
||||
"Seine gerade eingegangene Nachricht enthält vermutlich das Ergebnis dieses Wurfs — "
|
||||
"nimm die genannte Zahl direkt als Wurfergebnis, würfle NICHT selbst und frag nicht "
|
||||
"erneut danach, außer die Nachricht beantwortet die Anfrage erkennbar nicht."
|
||||
)
|
||||
# Consumed either way — if the message didn't actually answer it, the DM asking again
|
||||
# via roll_dice creates a fresh pending_roll for next time.
|
||||
participant.pending_roll = None
|
||||
await session.commit()
|
||||
|
||||
game = await session.get(Game, game_id)
|
||||
if game is not None:
|
||||
system_prompt = (
|
||||
@@ -105,6 +186,21 @@ async def run_dm_turn(
|
||||
"über den Einladungslink dazukommen; du erkennst sie automatisch am Namen, sobald sie schreiben."
|
||||
)
|
||||
|
||||
linked_characters = (
|
||||
await session.execute(
|
||||
select(Character)
|
||||
.join(GameParticipant, GameParticipant.character_id == Character.id)
|
||||
.where(GameParticipant.game_id == game_id)
|
||||
)
|
||||
).scalars().all()
|
||||
if linked_characters:
|
||||
sheets = "\n".join(_format_character_sheet(c) for c in linked_characters)
|
||||
system_prompt += (
|
||||
"\n\nBekannte Charakterblätter (bereits vollständig vorhanden — NICHT erneut nach "
|
||||
"Werten/Ausrüstung/Hintergrund fragen, nur bei tatsächlichen Änderungen über "
|
||||
f"upsert_character_sheet aktualisieren):\n{sheets}"
|
||||
)
|
||||
|
||||
await world_state.refresh_if_due(session, game_id, client, settings.dm_model)
|
||||
world_summary = await world_state.get_summary(session, game_id)
|
||||
if world_summary:
|
||||
@@ -145,9 +241,17 @@ async def run_dm_turn(
|
||||
|
||||
for tool_call in message.tool_calls:
|
||||
tool_input = fix_double_escaped_unicode(json.loads(tool_call.function.arguments))
|
||||
if tool_call.function.name == "roll_dice" and on_roll is not None:
|
||||
await on_roll(tool_input.get("notation", ""))
|
||||
result = await _execute_tool_call(session, game_id, tool_call.function.name, tool_input)
|
||||
|
||||
if tool_call.function.name == "roll_dice" and participant is not None and participant.self_rolls:
|
||||
notation = tool_input.get("notation", "")
|
||||
reason = tool_input.get("reason", "")
|
||||
result = await _request_player_roll(session, participant, notation, reason)
|
||||
if "error" not in result and on_awaiting_roll is not None:
|
||||
await on_awaiting_roll(notation, reason)
|
||||
else:
|
||||
if tool_call.function.name == "roll_dice" and on_roll is not None:
|
||||
await on_roll(tool_input.get("notation", ""))
|
||||
result = await _execute_tool_call(session, game_id, tool_call.function.name, tool_input)
|
||||
|
||||
end_reason = None
|
||||
if tool_call.function.name == "end_game" and "reason" in result:
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
Rolle
|
||||
Du bist ein erfahrener Dungeon Master und entwirfst gerade das Grundgerüst für ein neues D&D-Abenteuer, bevor das eigentliche Spiel beginnt. Nutze das, was im bisherigen Setup-Gespräch (Genre/Welt, Umfang, Erfahrungsstand der Spieler) sowie im vorgeschlagenen Namen und der Kurzbeschreibung erkennbar ist, um ein passendes, in sich stimmiges Abenteuer zu entwerfen.
|
||||
|
||||
Zielgruppe
|
||||
4 bis 6 Spielercharaktere, Stufe 1 zu Beginn, gedacht für einen One-Shot (ca. 3-5 Sitzungen à mehrere Stunden). Am Ende sollten die Charaktere durch das Bestehen der wichtigsten Stationen auf Stufe 2-3 aufgestiegen sein.
|
||||
|
||||
Aufbau
|
||||
Schreibe das Abenteuer als zusammenhängenden Fließtext (kein JSON, gerne mit Markdown-Überschriften) mit folgenden Abschnitten:
|
||||
|
||||
1. Titel und Prämisse: ein einprägsamer Titel, ein kurzer Absatz, worum es geht.
|
||||
2. Hintergrund: wer oder was ist die eigentliche Bedrohung, welche Motivation steckt dahinter (in-universe erzählt).
|
||||
3. Zusammenfassung: der grobe Handlungsbogen von Anfang bis Ende sowie die eindeutige Siegbedingung — wann gilt die Bedrohung als besiegt.
|
||||
4. Einstieg: ein bis zwei plausible Gründe (Hooks), warum die Charaktere gemeinsam losziehen.
|
||||
5. Drei bis vier Teile, die zusammen Anfang, Mitte und Ende des Abenteuers bilden. Jeder Teil bekommt einen Titel und enthält:
|
||||
- eine kurze Ortsbeschreibung
|
||||
- zwei bis vier nummerierte Orte oder Encounter mit knappem Vorlese-Text-Ansatz
|
||||
- benannte NPCs oder Monster mit kurzer Motivation/Verhalten
|
||||
- wo sinnvoll: ein bis zwei Fertigkeitswürfe mit Schwierigkeitsgrad (SG)
|
||||
- Hinweise auf Beute/Belohnungen, wo passend
|
||||
6. Meilenstein-Aufstieg: an welchen konkreten Punkten der Handlung die Gruppe eine Stufe aufsteigt (Meilenstein-System, kein XP-Tracking).
|
||||
7. Ende der Bedrohung: die explizite Bedingung, unter der das Abenteuer als erfolgreich abgeschlossen gilt.
|
||||
8. Hinweise für die Spielleitung: was tun, wenn die Spieler klar von der vorgesehenen Route abweichen; wie ein besonders schwerer Showdown-Kampf für eine Stufe-1-Gruppe fair entschärft werden kann.
|
||||
|
||||
Umfang
|
||||
Halte den Text auf einige tausend Wörter begrenzt — es soll ein kompaktes, spielbares Gerüst sein, keine ausführliche, seitenlange Kampagne. Monster-Werte müssen nicht im Detail ausformuliert werden (die Spielleitung hat Zugriff auf die vollständigen Regelwerke); es reicht, bekannte D&D-Monster oder -Archetypen beim Namen zu nennen.
|
||||
|
||||
Sprache
|
||||
Schreibe auf Deutsch, unabhängig davon, in welcher Sprache das bisherige Setup-Gespräch geführt wurde.
|
||||
@@ -4,7 +4,7 @@ Phase 1: Session 0 (Vorbereitung)
|
||||
Sobald du in ein neues Spiel geholt wirst, meldest du dich SOFORT von dir aus mit einer Begrüßung — warte nie erst auf eine Spielernachricht. Lernst du deine Spieler aktiv kennen. Prüfe zuerst die Spiel-Rahmendaten weiter unten (Name und Beschreibung, die der Ersteller beim Anlegen des Spiels bereits festgelegt hat, sowie die Liste der bereits beigetretenen Spieler) – Punkte, die dort schon beantwortet sind (z. B. Genre/Welt, Umfang, ggf. Erfahrungsstand, wer dabei ist), fragst du NICHT erneut ab, sondern bestätigst sie höchstens knapp im Gespräch. Wer mitspielt, ergibt sich ausschließlich aus den beigetretenen Spielern (Login/Einladungslink) – frage nie aktiv nach Anzahl oder Namen der Mitspieler; die Gruppe kann auch im Lauf der Sitzung noch wachsen. Stelle nur zu den noch offenen Punkten gezielt Fragen, eine nach der anderen, nicht alle auf einmal, an die Spieler, die bereits da sind:
|
||||
|
||||
1. Erfahrungsstand: Komplette Anfänger, Gelegenheitsspieler oder Regelkenner? Frage das pro Spieler ab, falls die Gruppe gemischt ist.
|
||||
2. Charaktere: Wollen sie eigene, fertige Charaktere mitbringen (Name, Klasse, Rasse, Stats) oder sollst du sie durch die Charaktererstellung führen?
|
||||
2. Charaktere: Wollen sie einen Charakter aus einem früheren Spiel wiederverwenden, einen komplett neuen, fertigen Charakter mitbringen (Name, Klasse, Rasse, Stats), oder sollst du sie durch die Charaktererstellung führen? Nennt jemand einen Charakternamen, den er schon einmal gespielt hat, rufe SOFORT link_existing_character auf, statt nach Werten zu fragen — das Werkzeug liefert dir das komplette vorhandene Blatt zurück (oder meldet, dass es keinen Treffer gibt, dann fragt du nach oder legt neu an).
|
||||
3. Regelkenntnis: Brauchen sie Erklärungen zu Grundmechaniken (Würfelwürfe, Angriffe, Rettungswürfe) oder reicht ein knapper Hinweis?
|
||||
4. 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, ...)?
|
||||
5. Umfang: Einzelnes One-Shot-Abenteuer (eine Sitzung) oder eine längere Kampagne über mehrere Sitzungen? Grobe Zeit, die sie investieren wollen?
|
||||
@@ -19,7 +19,8 @@ Phase 2: Das Spiel leiten
|
||||
- Fertigkeits-/Rettungswurf gegen einen bekannten Schwierigkeitsgrad: nenne Fähigkeit und SG direkt (z. B. „B) Die Läden zuwerfen (Stärke, SG 12)"), damit der Spieler seine Chancen vorher einschätzen kann.
|
||||
- Angriffswurf gegen die Rüstungsklasse eines Gegners: die RK ist geheimes DM-Wissen und wird nicht verraten, aber sag ausdrücklich, dass es ein Angriffswurf ist (z. B. „A) Angriff mit dem Streitkolben (Angriffswurf)"), damit klar ist, dass trotzdem gewürfelt wird.
|
||||
- Keine Probe nötig (z. B. reine Bewegung, automatischer Erfolg): das darfst du auch explizit so sagen.
|
||||
* Für Würfelproben: Nenne IMMER zuerst klar, was gewürfelt wird (Fähigkeit + SG, oder „Angriffswurf") – bevor gewürfelt wird. Würfle IMMER selbst über das roll_dice-Werkzeug, sofort, im selben Zug – fordere den Spieler NICHT auf, selbst zu würfeln oder dir ein Ergebnis zu nennen, und warte nicht auf eine Bestätigung. Das gilt ausnahmslos, außer ein Spieler hat ausdrücklich gesagt, dass er lieber mit eigenen physischen Würfeln spielt und dir die Ergebnisse selbst mitteilt (dann fragst du vorher, was er bevorzugt). Ohne eine solche Ansage würfelst du immer selbst, nie erfindest du ein Ergebnis. Erkläre das Ergebnis danach ausführlich in dieser Reihenfolge: (1) wogegen gewürfelt wurde (SG bei Proben; bei Angriffswürfen ohne die RK zu nennen), (2) gewürfeltes Ergebnis inklusive Bonus, (3) ob es reicht/trifft oder nicht – erst danach folgt die erzählerische Konsequenz.
|
||||
* Für Würfelproben: Nenne IMMER zuerst klar, was gewürfelt wird (Fähigkeit + SG, oder „Angriffswurf") – bevor gewürfelt wird. Rufe dafür IMMER das roll_dice-Werkzeug auf, sofort, im selben Zug – erfinde nie selbst ein Ergebnis und frage nicht erst, ob du würfeln sollst. Manche Spieler würfeln lieber mit eigenen physischen Würfeln (per Schalter in der Oberfläche eingestellt, nicht von dir gesteuert) – das erkennst du automatisch am Ergebnis von roll_dice: liefert es eine Zahl, erkläre sie sofort wie gewohnt; meldet es stattdessen "awaiting_player_roll", folge exakt der Anweisung im "note"-Feld (nenne die Notation, warte auf die Zahl in einer künftigen Nachricht, würfle nicht selbst). Erkläre ein echtes Ergebnis danach ausführlich in dieser Reihenfolge: (1) wogegen gewürfelt wurde (SG bei Proben; bei Angriffswürfen ohne die RK zu nennen), (2) gewürfeltes Ergebnis inklusive Bonus, (3) ob es reicht/trifft oder nicht – erst danach folgt die erzählerische Konsequenz.
|
||||
* Stehen unten "Bekannte Charakterblätter", sind diese Charaktere bereits vollständig vorhanden (z. B. aus einem früheren Spiel wiederverwendet) — frag NICHT erneut nach Werten, Ausrüstung oder Hintergrund, nutze die Daten direkt und aktualisiere nur bei echten Änderungen über upsert_character_sheet.
|
||||
* Trefferpunkte: Pflege current_hp/max_hp bei jeder Änderung (Schaden, Heilung, Erstellung) über upsert_character_sheet. Fällt ein Charakter auf 0 TP oder darunter, endet die Session automatisch serverseitig – erzähle das Ende dramaturgisch passend (Niederlage), aber erfinde danach keine Rettung oder Fortsetzung mehr.
|
||||
* Monster/NPCs in einem Kampf: Pflege deren TP ebenso über update_monster_hp bei jeder Änderung (Auftauchen, Schaden). Das ist reines Hintergrund-Tracking, nie den Spielern zeigen (RK/TP bleiben geheim wie bisher). Meldet das Werkzeug "defeated": true, ist der Gegner besiegt – erzähle das entsprechend, das beendet aber nicht automatisch die Session (nutze dafür bei Bedarf end_game).
|
||||
* Erreicht die Handlung einen echten Abschluss (Sieg, gelöste Haupthandlung eines One-Shots), rufe end_game mit einer kurzen Begründung auf. Nutze es nicht für bloße Szenenwechsel, sondern nur für ein echtes Ende der Geschichte.
|
||||
|
||||
@@ -9,7 +9,9 @@ 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."
|
||||
"Always use this tool for any dice roll that affects the game — never invent or guess a result yourself. "
|
||||
"If the player rolls their own physical dice, this tool won't return a number — it tells you to ask for "
|
||||
"the roll instead; see the tool result for exact wording."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
@@ -28,7 +30,10 @@ TOOL_SCHEMA = {
|
||||
}
|
||||
|
||||
|
||||
def roll(notation: str) -> dict:
|
||||
def parse_notation(notation: str) -> tuple[int, int, int]:
|
||||
"""Validates and parses dice notation into (count, sides, modifier). Raises ValueError on
|
||||
anything malformed or out of range — used both by roll() and to validate a notation when
|
||||
the player is rolling it themselves, before we ever touch the RNG."""
|
||||
match = _NOTATION_RE.match(notation)
|
||||
if not match:
|
||||
raise ValueError(f"Invalid dice notation: {notation!r}")
|
||||
@@ -44,6 +49,12 @@ def roll(notation: str) -> dict:
|
||||
if sign == "-":
|
||||
modifier = -modifier
|
||||
|
||||
return count, sides, modifier
|
||||
|
||||
|
||||
def roll(notation: str) -> dict:
|
||||
count, sides, modifier = parse_notation(notation)
|
||||
|
||||
rolls = [_rng.randint(1, sides) for _ in range(count)]
|
||||
total = sum(rolls) + modifier
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
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": "link_existing_character",
|
||||
"description": (
|
||||
"Call this when a player wants to bring a character they already created in a previous "
|
||||
"game, identified by name — instead of building a new one. Looks the character up among "
|
||||
"that player's own characters and links it to this game. Returns the full existing sheet "
|
||||
"(stats, combat stats, abilities, equipment, backstory) so you can continue with real data "
|
||||
"immediately instead of asking for it again. Do NOT use this to create a new character — "
|
||||
"use upsert_character_sheet for that."
|
||||
),
|
||||
"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_name": {
|
||||
"type": "string",
|
||||
"description": "The character's name, as the player stated it.",
|
||||
},
|
||||
},
|
||||
"required": ["player_name", "character_name"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def link(session: AsyncSession, game_id: uuid.UUID, tool_input: dict) -> dict:
|
||||
player_name = tool_input["player_name"]
|
||||
character_name = tool_input["character_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
|
||||
|
||||
# Only ever search the requesting player's own characters — never let the DM link a
|
||||
# character belonging to someone else, even if the name happens to match.
|
||||
candidates = (
|
||||
await session.execute(select(Character).where(Character.owner_id == user.id))
|
||||
).scalars().all()
|
||||
match = next(
|
||||
(c for c in candidates if c.name.strip().lower() == character_name.strip().lower()), None
|
||||
)
|
||||
if match is None:
|
||||
return {
|
||||
"found": False,
|
||||
"message": (
|
||||
f"No character named {character_name!r} found for {player_name}. Ask them to "
|
||||
"double-check the name, or offer to create a new character instead."
|
||||
),
|
||||
}
|
||||
|
||||
participant.character_id = match.id
|
||||
await session.commit()
|
||||
|
||||
return {
|
||||
"found": True,
|
||||
"character_id": str(match.id),
|
||||
"name": match.name,
|
||||
"race": match.race,
|
||||
"char_class": match.char_class,
|
||||
"level": match.level,
|
||||
"stats": match.stats,
|
||||
"combat_stats": match.combat_stats,
|
||||
"current_hp": match.current_hp,
|
||||
"max_hp": match.max_hp,
|
||||
"abilities": match.abilities,
|
||||
"equipment": match.equipment,
|
||||
"description": match.description,
|
||||
}
|
||||
@@ -51,4 +51,10 @@ class GameParticipant(Base):
|
||||
character_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("characters.id"), nullable=True
|
||||
)
|
||||
# Player preference: roll their own physical dice and report the result, instead of the
|
||||
# DM rolling for them via roll_dice.
|
||||
self_rolls: Mapped[bool] = mapped_column(nullable=False, default=False)
|
||||
# Set by roll_dice when self_rolls is on: {"notation": ..., "reason": ...} for the roll the
|
||||
# DM is waiting on this player to report. Cleared once the next turn consumes it.
|
||||
pending_roll: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||
joined_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False)
|
||||
|
||||
@@ -25,8 +25,14 @@ class GameRead(BaseModel):
|
||||
status: str
|
||||
ended_reason: str | None
|
||||
has_adventure: bool
|
||||
my_self_rolls: bool
|
||||
my_pending_roll: dict | None
|
||||
|
||||
|
||||
class GameJoin(BaseModel):
|
||||
participation_code: str
|
||||
character_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class SelfRollsUpdate(BaseModel):
|
||||
self_rolls: bool
|
||||
|
||||
@@ -15,3 +15,4 @@ class GameSetupChatResponse(BaseModel):
|
||||
messages: list[dict]
|
||||
assistant_text: str | None
|
||||
proposal: GameSetupProposal | None
|
||||
generated_adventure: str | None = None
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
fastapi==0.115.6
|
||||
fastapi==0.141.1
|
||||
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
|
||||
fastapi-users[sqlalchemy]==15.0.5
|
||||
openai>=1.50.0,<2.0.0
|
||||
aiosmtplib==3.0.2
|
||||
python-multipart==0.0.20
|
||||
aiosmtplib==5.1.2
|
||||
python-multipart==0.0.32
|
||||
websockets==14.1
|
||||
pgvector==0.3.6
|
||||
sentence-transformers==3.3.1
|
||||
sentence-transformers==6.0.1
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# Umzug auf einen anderen Rechner
|
||||
|
||||
Anleitung, um den aktuellen Stand (inkl. Datenbank) auf einem anderen Rechner mit bereits laufendem Docker neu aufzusetzen.
|
||||
|
||||
## Auf diesem Rechner (Vorbereitung)
|
||||
|
||||
Ein aktuelles Backup erstellen:
|
||||
|
||||
```bash
|
||||
bash scripts/backup-db.sh
|
||||
```
|
||||
|
||||
Zwei Dateien musst du selbst rüberkopieren (USB-Stick, Netzwerkfreigabe, scp — was im LAN am einfachsten ist):
|
||||
|
||||
- `.env` (Projekt-Root)
|
||||
- die soeben erstellte `backups/dnd_backup_<timestamp>.dump`
|
||||
|
||||
## Auf dem neuen Rechner
|
||||
|
||||
```bash
|
||||
git clone http://172.17.2.68:3001/nova/DungeonsDragons.git
|
||||
cd DungeonsDragons
|
||||
```
|
||||
|
||||
Dann `.env` in den Repo-Root legen, und die `.dump`-Datei z. B. in einen `backups/`-Unterordner.
|
||||
|
||||
### Wichtig — einen Wert in der übertragenen `.env` anpassen
|
||||
|
||||
```
|
||||
FRONTEND_ORIGIN=http://localhost:5173
|
||||
```
|
||||
|
||||
Das ist der Dev-Wert vom ursprünglichen Rechner. Auf dem neuen sollte es die tatsächliche Adresse sein, unter der das Frontend dort erreichbar ist (z. B. `http://<neue-IP>:8080`) — wird für CORS und Passwort-Reset-Links gebraucht.
|
||||
|
||||
Alles andere (DB-Zugangsdaten, x.ai-Key, JWT-Secrets) kann unverändert bleiben — gleiche Secrets heißt bestehende Logins bleiben gültig.
|
||||
|
||||
### Datenbank zuerst separat hochziehen und befüllen, bevor der Rest startet
|
||||
|
||||
```bash
|
||||
docker compose up -d postgres
|
||||
# kurz warten, bis er "healthy" ist (docker compose ps)
|
||||
bash scripts/restore-db.sh backups/dnd_backup_<timestamp>.dump -y
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Der Restore bringt Schema und Daten in einem Rutsch mit (kein separates `alembic upgrade` nötig — der Dump enthält den kompletten aktuellen Migrationsstand). Die drei Regelwerk-Textdateien (`backend/rulebooks/`) sind bereits im Git-Repo enthalten, brauchen also keinen Extra-Transfer.
|
||||
|
||||
### Check danach
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/health
|
||||
```
|
||||
|
||||
und Frontend im Browser unter `http://<neuer-Rechner>:8080` aufrufen.
|
||||
@@ -15,6 +15,8 @@ export interface Game {
|
||||
status: "active" | "ended";
|
||||
ended_reason: string | null;
|
||||
has_adventure: boolean;
|
||||
my_self_rolls: boolean;
|
||||
my_pending_roll: { notation: string; reason: string } | null;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
@@ -37,6 +39,7 @@ export interface GameSetupChatResponse {
|
||||
messages: Record<string, unknown>[];
|
||||
assistant_text: string | null;
|
||||
proposal: GameSetupProposal | null;
|
||||
generated_adventure: string | null;
|
||||
}
|
||||
|
||||
export const listGames = () => apiFetch<Game[]>("/api/games");
|
||||
@@ -62,6 +65,12 @@ export const getGame = (id: string) => apiFetch<Game>(`/api/games/${id}`);
|
||||
|
||||
export const deleteGame = (id: string) => apiFetch<void>(`/api/games/${id}`, { method: "DELETE" });
|
||||
|
||||
export const setSelfRolls = (id: string, selfRolls: boolean) =>
|
||||
apiFetch<Game>(`/api/games/${id}/self-rolls`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ self_rolls: selfRolls }),
|
||||
});
|
||||
|
||||
export const joinGame = (id: string, participation_code: string, character_id?: string) =>
|
||||
apiFetch<Game>(`/api/games/${id}/join`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -15,6 +15,7 @@ export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) =
|
||||
const [rawMessages, setRawMessages] = useState<Record<string, unknown>[]>([]);
|
||||
const [turns, setTurns] = useState<DisplayTurn[]>([]);
|
||||
const [proposal, setProposal] = useState<GameSetupProposal | null>(null);
|
||||
const [generatedAdventure, setGeneratedAdventure] = useState<string | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -26,6 +27,7 @@ export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) =
|
||||
messages: Record<string, unknown>[];
|
||||
assistant_text: string | null;
|
||||
proposal: GameSetupProposal | null;
|
||||
generated_adventure?: string | null;
|
||||
}) {
|
||||
setRawMessages(res.messages);
|
||||
if (res.assistant_text) {
|
||||
@@ -42,6 +44,9 @@ export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) =
|
||||
]);
|
||||
}
|
||||
}
|
||||
if (res.generated_adventure) {
|
||||
setGeneratedAdventure(res.generated_adventure);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -79,7 +84,7 @@ export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) =
|
||||
setCreating(true);
|
||||
setError(null);
|
||||
try {
|
||||
const game = await createGame(name, description, adventureText);
|
||||
const game = await createGame(name, description, adventureText || generatedAdventure || "");
|
||||
onCreated(game);
|
||||
} catch {
|
||||
setError("Spiel konnte nicht erstellt werden.");
|
||||
@@ -129,7 +134,13 @@ export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) =
|
||||
<MarkdownContent content={turn.content} />
|
||||
</div>
|
||||
))}
|
||||
{loading && <p className="text-sm text-slate-500">Der Dungeon Master überlegt…</p>}
|
||||
{loading && (
|
||||
<p className="text-sm text-slate-500">
|
||||
{adventureText.trim()
|
||||
? "Der Dungeon Master überlegt…"
|
||||
: "Der Dungeon Master überlegt … und entwirft bei Bedarf ein Abenteuer (kann einen Moment dauern)"}
|
||||
</p>
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
<SendMessageForm onSend={handleSend} disabled={loading} placeholder="Deine Antwort..." />
|
||||
@@ -150,6 +161,14 @@ export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) =
|
||||
className="rounded bg-slate-800 px-3 py-2 outline-none focus:ring-2 focus:ring-amber-400"
|
||||
rows={3}
|
||||
/>
|
||||
{!adventureText.trim() && generatedAdventure && (
|
||||
<details className="rounded border border-slate-800 bg-slate-950 p-3 text-sm text-slate-300">
|
||||
<summary className="cursor-pointer font-semibold text-amber-400">
|
||||
Abenteuer-Entwurf ansehen (wird automatisch verwendet)
|
||||
</summary>
|
||||
<div className="mt-2 max-h-64 overflow-y-auto whitespace-pre-wrap">{generatedAdventure}</div>
|
||||
</details>
|
||||
)}
|
||||
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
@@ -157,7 +176,7 @@ export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) =
|
||||
className="self-start rounded bg-amber-500 px-4 py-2 text-sm font-semibold text-slate-950 hover:bg-amber-400 disabled:opacity-50"
|
||||
>
|
||||
{creating
|
||||
? adventureText.trim()
|
||||
? adventureText.trim() || generatedAdventure
|
||||
? "Erstelle… (Abenteuertext wird verarbeitet, kann etwas dauern)"
|
||||
: "Erstelle…"
|
||||
: "Erstellen"}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { Message } from "../api/games";
|
||||
import { AwaitingRoll } from "../hooks/useGameSocket";
|
||||
import MarkdownContent from "./MarkdownContent";
|
||||
|
||||
const DICE_FACES = ["⚀", "⚁", "⚂", "⚃", "⚄", "⚅"];
|
||||
@@ -37,16 +38,18 @@ export default function TranscriptPane({
|
||||
messages,
|
||||
typing,
|
||||
rolling,
|
||||
awaitingRoll,
|
||||
}: {
|
||||
messages: Message[];
|
||||
typing?: boolean;
|
||||
rolling?: string | null;
|
||||
awaitingRoll?: AwaitingRoll | null;
|
||||
}) {
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ block: "end" });
|
||||
}, [messages, typing, rolling]);
|
||||
}, [messages, typing, rolling, awaitingRoll]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-3 overflow-y-auto p-4">
|
||||
@@ -66,7 +69,16 @@ export default function TranscriptPane({
|
||||
<MarkdownContent content={m.content} />
|
||||
</div>
|
||||
))}
|
||||
{rolling != null ? (
|
||||
{awaitingRoll ? (
|
||||
<div className="rounded-lg border border-amber-500/50 bg-amber-500/10 px-4 py-3">
|
||||
<span className="font-semibold text-amber-400">🎲 Du bist am Zug — würfle:</span>{" "}
|
||||
<span className="font-mono text-slate-100">{awaitingRoll.notation}</span>
|
||||
{awaitingRoll.reason && <span className="text-sm text-slate-400"> ({awaitingRoll.reason})</span>}
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
Wirf deinen Würfel und schreib das Ergebnis als Nachricht.
|
||||
</p>
|
||||
</div>
|
||||
) : rolling != null ? (
|
||||
<DiceRollAnimation notation={rolling} />
|
||||
) : (
|
||||
typing && (
|
||||
|
||||
@@ -3,6 +3,11 @@ import { useEffect, useRef } from "react";
|
||||
import { apiFetch } from "../api/client";
|
||||
import { Message } from "../api/games";
|
||||
|
||||
export interface AwaitingRoll {
|
||||
notation: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export function useGameSocket(
|
||||
gameId: string | undefined,
|
||||
onMessage: (message: Message) => void,
|
||||
@@ -10,6 +15,7 @@ export function useGameSocket(
|
||||
onTyping: () => void,
|
||||
onRolling: (notation: string) => void,
|
||||
onGameEnded: (reason: string) => void,
|
||||
onAwaitingRoll: (roll: AwaitingRoll) => void,
|
||||
) {
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const onMessageRef = useRef(onMessage);
|
||||
@@ -22,6 +28,8 @@ export function useGameSocket(
|
||||
onRollingRef.current = onRolling;
|
||||
const onGameEndedRef = useRef(onGameEnded);
|
||||
onGameEndedRef.current = onGameEnded;
|
||||
const onAwaitingRollRef = useRef(onAwaitingRoll);
|
||||
onAwaitingRollRef.current = onAwaitingRoll;
|
||||
|
||||
useEffect(() => {
|
||||
if (!gameId) return;
|
||||
@@ -43,6 +51,8 @@ export function useGameSocket(
|
||||
else if (data.type === "typing") onTypingRef.current();
|
||||
else if (data.type === "rolling") onRollingRef.current(data.notation ?? "");
|
||||
else if (data.type === "game_ended") onGameEndedRef.current(data.reason ?? "");
|
||||
else if (data.type === "awaiting_roll")
|
||||
onAwaitingRollRef.current({ notation: data.notation ?? "", reason: data.reason ?? "" });
|
||||
};
|
||||
ws.onclose = () => {
|
||||
if (!intentionalClose) onErrorRef.current("connection_lost");
|
||||
|
||||
@@ -2,10 +2,18 @@ import { FormEvent, useEffect, useState } from "react";
|
||||
import { Link, useParams, useSearchParams } from "react-router-dom";
|
||||
|
||||
import { listCharacters, Character } from "../api/characters";
|
||||
import { Game, getFullMessages, getGame, getRecentMessages, joinGame, Message } from "../api/games";
|
||||
import {
|
||||
Game,
|
||||
getFullMessages,
|
||||
getGame,
|
||||
getRecentMessages,
|
||||
joinGame,
|
||||
Message,
|
||||
setSelfRolls,
|
||||
} from "../api/games";
|
||||
import SendMessageForm from "../components/SendMessageForm";
|
||||
import TranscriptPane from "../components/TranscriptPane";
|
||||
import { useGameSocket } from "../hooks/useGameSocket";
|
||||
import { AwaitingRoll, useGameSocket } from "../hooks/useGameSocket";
|
||||
|
||||
export default function GameSession() {
|
||||
const { gameId } = useParams<{ gameId: string }>();
|
||||
@@ -23,12 +31,15 @@ export default function GameSession() {
|
||||
const [isTyping, setIsTyping] = useState(false);
|
||||
const [rollingNotation, setRollingNotation] = useState<string | null>(null);
|
||||
const [endedReason, setEndedReason] = useState<string | null>(null);
|
||||
const [awaitingRoll, setAwaitingRoll] = useState<AwaitingRoll | null>(null);
|
||||
const [selfRollsSaving, setSelfRollsSaving] = useState(false);
|
||||
|
||||
async function loadGame() {
|
||||
if (!gameId) return;
|
||||
const g = await getGame(gameId);
|
||||
setGame(g);
|
||||
setEndedReason(g.status === "ended" ? g.ended_reason : null);
|
||||
setAwaitingRoll(g.my_pending_roll ?? null);
|
||||
return g;
|
||||
}
|
||||
|
||||
@@ -65,6 +76,7 @@ export default function GameSession() {
|
||||
try {
|
||||
const g = await joinGame(gameId, joinCode, selectedCharacterId || undefined);
|
||||
setGame(g);
|
||||
setAwaitingRoll(g.my_pending_roll ?? null);
|
||||
await loadRecent();
|
||||
} catch {
|
||||
setJoinError("Beitritt fehlgeschlagen — Teilnahmecode prüfen.");
|
||||
@@ -106,9 +118,23 @@ export default function GameSession() {
|
||||
() => setIsTyping(true),
|
||||
(notation) => setRollingNotation(notation),
|
||||
(reason) => setEndedReason(reason),
|
||||
(roll) => setAwaitingRoll(roll),
|
||||
);
|
||||
|
||||
async function handleToggleSelfRolls(checked: boolean) {
|
||||
if (!gameId) return;
|
||||
setSelfRollsSaving(true);
|
||||
try {
|
||||
const updated = await setSelfRolls(gameId, checked);
|
||||
setGame(updated);
|
||||
if (!checked) setAwaitingRoll(null);
|
||||
} finally {
|
||||
setSelfRollsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSend(content: string) {
|
||||
setAwaitingRoll(null);
|
||||
send(content);
|
||||
}
|
||||
|
||||
@@ -168,7 +194,17 @@ export default function GameSession() {
|
||||
</h1>
|
||||
<p className="text-xs text-slate-500">{game.description}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="flex items-center gap-1.5 text-sm text-slate-400" title="Statt dass der Dungeon Master für dich würfelt, sagt er dir nur noch, was zu würfeln ist — du würfelst selbst und teilst das Ergebnis mit.">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={game.my_self_rolls}
|
||||
disabled={selfRollsSaving}
|
||||
onChange={(e) => handleToggleSelfRolls(e.target.checked)}
|
||||
className="h-4 w-4 accent-amber-500"
|
||||
/>
|
||||
Ich würfle selbst
|
||||
</label>
|
||||
{game.my_character_id && (
|
||||
<Link
|
||||
to={`/characters/${game.my_character_id}?back=${gameId}`}
|
||||
@@ -193,7 +229,12 @@ export default function GameSession() {
|
||||
)}
|
||||
|
||||
<div className="flex flex-1 flex-col overflow-hidden rounded-lg border border-slate-800 bg-slate-900">
|
||||
<TranscriptPane messages={messages} typing={isTyping} rolling={rollingNotation} />
|
||||
<TranscriptPane
|
||||
messages={messages}
|
||||
typing={isTyping}
|
||||
rolling={rollingNotation}
|
||||
awaitingRoll={awaitingRoll}
|
||||
/>
|
||||
<SendMessageForm onSend={handleSend} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user