Compare commits

...

16 Commits

Author SHA1 Message Date
Thorsten 7c374f2bc2 Add server migration guide
Documents the backup/restore-based steps for moving the app to a new
machine with Docker already installed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 12:18:57 +02:00
Thorsten f9b9785857 Auto-generate an adventure outline when no adventure text is provided
When a player creates a game without pasting their own adventure, the
DM previously improvised turn-by-turn with no overall arc, ending, or
calibrated difficulty. Now the setup-chat interview generates a full
outline (hook, three-to-four-part structure, milestone leveling,
explicit ending condition, GM guidance) as a follow-up LLM call once
genre/length/experience are known, and feeds it through the existing
adventure_text -> RAG ingestion -> "follow strictly" pipeline exactly
like a pasted adventure. Falls back silently to today's improvised
behavior if generation fails.

Verified end-to-end in the browser: setup interview -> outline
generated and previewed -> game created -> RAG chunks ingested ->
has_adventure true -> cascade-cleaned on delete.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 11:58:42 +02:00
Thorsten 1b64010280 Fix stale DM_MODEL default in .env.example
grok-4.6 was never a real x.ai model id; the live .env has used
grok-4.3 for a while, .env.example just hadn't caught up.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 21:26:10 +02:00
Thorsten d62970ee1b Fix backend dependency vulnerabilities (36 CVEs across 6 packages)
Upgrades fastapi (0.115.6->0.141.1, pulling starlette to 1.6.0),
fastapi-users (14.0.1->15.0.5), aiosmtplib (3.0.2->5.1.2),
python-multipart (0.0.20->0.0.32), and sentence-transformers
(3.3.1->6.0.1, pulling transformers and pyjwt to patched versions).

Verified via pip-audit (zero remaining findings across all 97
resolved dependencies) and functional smoke tests: auth flow, RAG
embedding/retrieval (dimension still 384, similarity still sane),
DM-turn tool-calling (roll_dice still fires correctly), and a live
WebSocket handshake/send/broadcast round trip against the upgraded
Starlette.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 21:26:10 +02:00
Thorsten cc680dc392 Let the DM reuse an existing character instead of re-creating it
Characters are already owned by the player, not the game, and were
meant to be reusable — but the DM had no way to actually retrieve one.
upsert_character_sheet is write-only, so telling the DM "I'll bring my
character Tillo Eichenherz" led it to create a second, blank
"Tillo Eichenherz" (empty stats, thinner ability text) instead of
reusing the real one, then ask for all six ability scores again as if
nothing existed.

Two fixes:

1. New link_existing_character tool: given a player name and a
   character name, looks it up among that player's own characters
   (ownership enforced — never someone else's) and links it to
   game_participants.character_id, returning the full sheet so the DM
   can continue with real data immediately. System prompt now tells
   the DM to call this the moment a player names a previously-played
   character, instead of asking for stats.

2. run_dm_turn now auto-injects the full sheet of every already-linked
   character into the system prompt every turn (name, stats, combat
   stats, HP, abilities, equipment, background), with an explicit
   "already exists, don't ask again" instruction — this is the actual
   root-cause fix: even a character linked some other way (the
   existing join-with-character_id flow, or a future UI) now stays
   visible to the DM turn after turn instead of only living in
   whatever's left of the truncated chat window.

Also manually repaired the user's real "Schwarze Segel" game, which
had already hit this bug — re-pointed its participant back to the
real Tillo Eichenherz instead of the accidental duplicate (left the
duplicate in place rather than deleting data; it's a normal delete
away via the character-delete feature if unwanted).

Verified live against x.ai: a fresh game where the player said "Ich
nehme meinen alten Charakter Tillo Eichenherz" linked to the real
character on the first message (no duplicate created — confirmed by
row count), and a follow-up question about AC/Strength got the exact
real values (RK 18, Stärke 15) pulled from the injected sheet, not
re-asked or invented.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 14:48:22 +02:00
Thorsten f5c62d27fd Let players roll their own physical dice instead of the DM
Adds the "Ich würfle selbst" checkbox the user asked for, plus the
structural piece that makes it actually reliable: a tool call can't
block mid-turn waiting for a human to go find a d20, so this can't be
a pure client-side toggle — the DM has to think in two turns (ask,
then later recognize the answer), and it's easy for an LLM to lose
track of that across a real gap in the conversation.

game_participants gets self_rolls (the toggle) and pending_roll (what
roll is currently awaited, if any). When self_rolls is on, roll_dice
doesn't touch the RNG — it validates the notation, stores it as
pending_roll, and returns an "awaiting_player_roll" result that tells
the DM to ask for that exact roll and wait, never inventing a number.
At the top of the player's next turn, orchestrator.run_dm_turn reads
back any pending_roll, injects it as an explicit reminder into the
system prompt ("the player's message is probably answering this"),
and clears it — so the DM doesn't have to rely on remembering what it
asked for several messages ago.

Frontend: the checkbox lives in the game header (PATCH
/api/games/{id}/self-rolls, per-participant). A persistent "🎲 Du bist
am Zug" banner shows the pending notation/reason — persistent, not
transient like the typing/rolling indicators, since answering it means
physically finding a die and rolling, which takes real time. It's
seeded from GameRead.my_pending_roll on load so it survives a page
reload, and only clears when the player actually sends their next
message (not when the DM's message arrives, which would make it
disappear before there was time to read it).

Verified live end-to-end against x.ai in a real session: toggled the
setting, asked for something requiring a check, got "würfle bitte
1d20+1" instead of an auto-rolled result, confirmed the banner
survived a full page reload, replied "14", and the DM used that
number directly ("Du hast 14 gewürfelt — das reicht") without
re-rolling — with pending_roll correctly cleared in the DB afterward.
Also confirmed per-participant scoping (a second participant's flag
stayed off) and that toggling back off works cleanly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 13:59:48 +02:00
Thorsten fb19795fdb Add pg_dump/pg_restore backup and restore scripts
For moving this project to a new server: git tracks code, not the
Postgres data directory, and raw volume/data-dir copying is fragile
across Postgres versions or architectures (this repo already hit a
collation-version mismatch once from just changing the Postgres
image). pg_dump/pg_restore is the portable way to move the actual
data instead.

scripts/backup-db.sh dumps the running postgres container's database
in pg_dump's custom format into ./backups/ (gitignored — dumps
contain real user data and don't belong in git history). It reads
POSTGRES_USER/POSTGRES_DB from the container's own environment rather
than re-parsing .env, so it can't drift out of sync with the actual
running config.

scripts/restore-db.sh restores a dump into the running container
(--clean --if-exists --single-transaction, so a failure rolls back
atomically instead of leaving a half-restored database), with a
confirmation prompt since it's destructive, skippable via -y.

Added .gitattributes forcing LF on *.sh — Windows git would otherwise
check these out as CRLF, breaking the bash shebang on the Linux
server they're meant to run on.

Verified end to end: took a real backup of the live database, restored
it back over itself, and confirmed exact row-count matches across every
table plus that pgvector similarity search still works on the restored
embeddings (not just that the bytes round-tripped).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 13:04:00 +02:00
Thorsten a58730fce7 Let owners delete their games and characters, with confirmation
Add DELETE /api/games/{id} (creator-only) and DELETE
/api/characters/{id} (owner-only), each 403ing for anyone else.

Deleting a game relies on the existing cascade FKs (game_participants,
messages, world_state, adventure_chunks all cascade on games.id) — a
plain session.delete() is enough.

Deleting a character is trickier: game_participants.character_id and
messages.character_id reference it with no ON DELETE clause (a
character can outlive a game and vice versa), so deleting one that's
been played would hit a FK violation. Null out both references first
— game and message history stay intact, just detached from the
now-gone character, same as how a participant with no character
selected already renders.

Frontend: a reusable ConfirmDialog component, a "Löschen" button on
GameCard/CharacterCard visible only to the owner (checked against
useAuth()'s user id), wired through GamesList/CharactersList so the
list updates locally after a successful delete.

Verified: isolated backend test proved the character delete's FK
nulling works without violation and preserves the game/message rows;
live in the browser, deleting a game removed it from the list with no
orphaned rows left in any of the four related tables, and the
character delete dialog's cancel path correctly leaves everything
untouched. Ownership scoping confirmed live too — no delete button
appears on other users' games.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 12:14:48 +02:00
Thorsten b75cfb1437 Serve the frontend from docker-compose via nginx
Milestone 9 originally planned Caddy as the reverse proxy handling
both static serving and TLS. That's changed: the user has their own
external nginx that will handle SSL/certs and route to this app once
it's exposed beyond the LAN — so this compose stack only needs to
serve the frontend over plain HTTP for now, no TLS layer of its own.

Rewrote frontend/Dockerfile from the old scratch/dist-only build
(meant to hand its output to Caddy) into a self-contained nginx image:
builds the Vite app, then serves it from nginx on port 80. Added
frontend/nginx.conf, which also reverse-proxies /api, /auth, /users,
/ws, and /health to the backend container — necessary because
api/client.ts calls relative paths, so the SPA and API need to appear
as one origin to the browser (same pattern the Vite dev-server proxy
already used, now the production equivalent). The /ws location sets
the Upgrade/Connection headers for the WebSocket handshake and a long
proxy_read_timeout, since DM turns can take well over nginx's 60s
default before the connection sees more traffic.

docker-compose.yml gets a frontend service, port 8080:80.

Verified: docker compose up -d --build frontend; confirmed the built
SPA loads, /api and /health proxy through to the backend (401/200,
not 502), and — the part most likely to break — a real WebSocket
opened successfully through the proxy and a full message round-trip
(send -> persist -> broadcast -> typing -> DM turn) worked end to end
against the containerized stack, not just the Vite dev server.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 16:27:23 +02:00
Thorsten 7bbdee868a DM speaks first automatically; stop asking who's playing
Two issues from live testing: (1) the DM only ever spoke after a
player sent something, so starting a fresh game meant typing a
throwaway message like "start" just to get it going; (2) Session 0
asked for player headcount and names even though that's already
known — everyone who wants in has already logged in and joined via
the invite link/code, tracked in game_participants.

Fix 1: ws_game.py now runs a DM turn immediately on the first-ever
WebSocket connection to a game with zero messages, before entering
the normal receive loop — guarded by the game's existing lock plus a
"does this game have messages yet" check, so two tabs opening around
the same time can't produce two greetings. Extracted the
run-turn-then-broadcast logic (typing/rolling/game_ended/message)
into _run_and_broadcast_dm_turn, shared between the kickoff and the
normal per-message path instead of duplicated.

Fix 2: orchestrator.run_dm_turn() now queries game_participants and
injects the actual joined-player names into the system prompt
alongside the game's name/description. dm_system_prompt.txt drops the
"ask for player count and names" question entirely and instructs the
DM to greet whoever's already listed instead, since more players can
join mid-session and get recognized automatically once they speak.

Verified live: a freshly created game showed "Dungeon Master schreibt
…" and then a real opening message addressed to "Thorsten" by name
before any player message existed, skipped straight to the character
question, and reloading the page didn't produce a duplicate greeting.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 14:04:42 +02:00
Thorsten 07665595a2 Move adventure-text field to the front of game setup
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>
2026-09-02 13:35:45 +02:00
Thorsten b77f066259 Add per-game adventure text with RAG-based retrieval
Players can now paste the full text of a freely available adventure
when creating a game, and the DM will follow it instead of inventing
its own plot/NPCs/locations. Reuses the existing rulebook RAG
pipeline (chunking, local embedding) rather than injecting the raw
text into every turn, since real adventure modules range from a few
pages to hundreds — far beyond what fits in a prompt.

- New adventure_chunks table (game-scoped, unlike the global
  rulebook_chunks) + Game.adventure_text storing the raw source.
- ingest_adventure_text() chunks/embeds synchronously during
  POST /api/games when adventure_text is non-empty.
- build_adventure_rag_block() retrieves by similarity to the latest
  player message, scoped to game_id — plus always includes chunk 0
  (the adventure's opening) regardless of the query, since early
  Session-0 messages rarely resemble the adventure's actual hook and
  pure similarity search could miss the beginning entirely.
- System prompt instructs the DM to follow provided adventure
  excerpts strictly, deviating only when players clearly go off-script.
- Frontend: optional adventure-text field in the game creation form,
  a has_adventure flag surfaced as a small badge on the game card and
  session header.

Verified with an isolated two-game test that chunks/retrieval never
leak across games (the critical failure mode here), and live against
x.ai: a custom-written one-shot's specific NPCs, location, and hook
appeared verbatim in the DM's opening scene instead of invented ones.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 13:13:59 +02:00
Thorsten ff4c6f07e3 Make the participation code copyable as a shareable invite link
The code was shown as plain text on the game card with no way to copy
it. It's now a button that copies a full invite link
(origin/games/{id}?code={code}) to the clipboard instead of just the
bare code, since the recipient still needs the game id to get there.

GameSession's join form reads that ?code= param on mount and
pre-fills the input, so following the link lands straight on "click
Beitreten" instead of needing the code typed in by hand.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 18:15:47 +02:00
Thorsten a8763e175f Add a hard trigger for world-state summarization
update_world_state relied entirely on the DM remembering to call it —
no code checked whether it actually happened. Add refresh_if_due(),
called at the top of every DM turn: it counts messages since the
summary was last touched (by the tool or a prior auto-refresh), and
once AUTO_REFRESH_MESSAGE_THRESHOLD (10) is crossed, forces a
dedicated summarization call — a separate, tool-free completion whose
only job is to fold the new messages into the existing summary — and
persists the result directly, without waiting on the main DM turn's
discretion.

The trigger condition (message count) is deterministic; only the
summary text itself still needs an LLM, which is unavoidable for a
task that requires understanding, not just counting.

context.py gained count_messages_since() and build_transcript_text()
(an uncapped, since-filtered plain-text transcript for the
summarizer, as opposed to build_context()'s char-budget-capped chat
list for the main DM call) — factored out of the same underlying
message-labeling logic to avoid duplicating the name-resolution joins.

Verified with a fake LLM client: confirmed the trigger fires exactly
at the threshold and not before, resets after firing, and that the
second refresh's prompt carries the prior summary forward while only
including messages since that refresh (not the whole history again).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 18:12:10 +02:00
Thorsten 81eaf667cd Implement the world-state summarization from the Phase 2 plan
Context was pure full-text replay of every message, capped at 40k
chars with oldest-dropped-first — no summarization/world-state layer,
as called out as still-missing in an earlier conversation.

Add a new update_world_state DM tool that persists a compact,
DM-authored recap (key NPCs, current location, open plot threads,
party/inventory state) to a new world_state table, one row per game.
It's injected into the system prompt every turn, ahead of the raw
message window. The DM is instructed to call it regularly — at every
scene change or major event, not just at the end — sending the full
current picture each time (matches the replace-not-merge pattern
already used for combat_stats/abilities/equipment).

Since the summary now backs up everything older, shrink the raw
message window from 40k to 16k chars — it's recent continuity now,
not the sole memory of the session. Full history remains available
via "Volltext laden" regardless, since that reads the messages table
directly rather than through this context builder.

Simplified from the original plan sketch (state JSONB) to a single
free-text summary field — natural-language recaps are something an
LLM authors well; a structured world model would need a schema the
DM would have to conform to for no real benefit here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 17:59:57 +02:00
Thorsten 2b2af058b8 Add monster HP tracking, separate from the game-ending logic
Only the player character's HP was tracked before, so a monster's
death was never detected — only the DM's own (unenforced) narration
of it. Add a new update_monster_hp tool the DM calls whenever an
NPC/monster is introduced or takes damage, stored as DM-only
bookkeeping on Game (never exposed via GameRead, since HP/AC of NPCs
is meant to stay secret from players). The tool reports back
"defeated": true once HP drops to 0 so the DM can react to it — but
unlike a character dying, this does NOT auto-end the session; whether
a monster's death should end the game is a separate decision left to
the DM's own end_game call, or to a future rule.

Building an isolated test for this caught a real bug along the way:
the update mutated the matched monster's dict in place before
reassigning the list, which made SQLAlchemy's old-vs-new JSONB
comparison see identical content and silently skip writing the
change. Fixed by building fresh dicts instead of mutating shared ones.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 17:50:39 +02:00
48 changed files with 1591 additions and 134 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ LLM_PROVIDER=xai
# x.ai API (Grok) — used when LLM_PROVIDER=xai. Get a key at https://console.x.ai # x.ai API (Grok) — used when LLM_PROVIDER=xai. Get a key at https://console.x.ai
XAI_API_KEY= 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 # 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 # LAN IP — the backend runs inside Docker and can't necessarily reach the host's LAN address
+2
View File
@@ -0,0 +1,2 @@
* text=auto
*.sh text eol=lf
+1
View File
@@ -12,6 +12,7 @@ build/
caddy_data/ caddy_data/
caddy_config/ caddy_config/
pgdata/ pgdata/
backups/
frontend/dist/ frontend/dist/
*.tsbuildinfo *.tsbuildinfo
.DS_Store .DS_Store
@@ -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')
@@ -0,0 +1,32 @@
"""add game monsters (DM-only HP tracking)
Revision ID: d4b2c8a09e51
Revises: c9a1d5e7f316
Create Date: 2026-09-01 17: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 = 'd4b2c8a09e51'
down_revision: Union[str, None] = 'c9a1d5e7f316'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
'games',
sa.Column(
'monsters', postgresql.JSONB(astext_type=sa.Text()), nullable=False,
server_default='[]',
),
)
def downgrade() -> None:
op.drop_column('games', 'monsters')
@@ -0,0 +1,33 @@
"""add world_state table
Revision ID: e5f7a1c3b982
Revises: d4b2c8a09e51
Create Date: 2026-09-01 18: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 = 'e5f7a1c3b982'
down_revision: Union[str, None] = 'd4b2c8a09e51'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'world_state',
sa.Column('game_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('summary', sa.Text(), nullable=False, server_default=''),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['game_id'], ['games.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('game_id'),
)
def downgrade() -> None:
op.drop_table('world_state')
@@ -0,0 +1,45 @@
"""add adventure_text on games and adventure_chunks table (per-game RAG)
Revision ID: f1a2b3c4d5e6
Revises: e5f7a1c3b982
Create Date: 2026-09-02 10:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from pgvector.sqlalchemy import Vector
# revision identifiers, used by Alembic.
revision: str = 'f1a2b3c4d5e6'
down_revision: Union[str, None] = 'e5f7a1c3b982'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
'games',
sa.Column('adventure_text', sa.Text(), nullable=False, server_default=''),
)
op.create_table(
'adventure_chunks',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('game_id', sa.UUID(), nullable=False),
sa.Column('chunk_index', sa.Integer(), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('embedding', Vector(384), nullable=False),
sa.ForeignKeyConstraint(['game_id'], ['games.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(
op.f('ix_adventure_chunks_game_id'), 'adventure_chunks', ['game_id'], unique=False
)
def downgrade() -> None:
op.drop_index(op.f('ix_adventure_chunks_game_id'), table_name='adventure_chunks')
op.drop_table('adventure_chunks')
op.drop_column('games', 'adventure_text')
+30 -1
View File
@@ -1,12 +1,14 @@
import uuid import uuid
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.users import current_active_user from app.auth.users import current_active_user
from app.db import get_async_session from app.db import get_async_session
from app.models.character import Character from app.models.character import Character
from app.models.game import GameParticipant
from app.models.message import Message
from app.models.user import User from app.models.user import User
from app.schemas.character import CharacterRead from app.schemas.character import CharacterRead
@@ -40,3 +42,30 @@ async def get_character(
if character.owner_id != user.id: if character.owner_id != user.id:
raise HTTPException(status_code=403, detail="Not your character") raise HTTPException(status_code=403, detail="Not your character")
return character return character
@router.delete("/{character_id}", status_code=204)
async def delete_character(
character_id: uuid.UUID,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
) -> None:
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")
# game_participants.character_id and messages.character_id have no ON DELETE cascade
# (a character can outlive its games and vice versa) — detach them first so the delete
# doesn't hit a FK violation. Games and message history stay intact either way.
await session.execute(
update(GameParticipant)
.where(GameParticipant.character_id == character_id)
.values(character_id=None)
)
await session.execute(
update(Message).where(Message.character_id == character_id).values(character_id=None)
)
await session.delete(character)
await session.commit()
+58 -4
View File
@@ -13,7 +13,8 @@ from app.models.character import Character
from app.models.game import Game, GameParticipant from app.models.game import Game, GameParticipant
from app.models.message import Message from app.models.message import Message
from app.models.user import User from app.models.user import User
from app.schemas.game import GameCreate, GameJoin, GameRead from app.rag.adventure_ingestion import ingest_adventure_text
from app.schemas.game import GameCreate, GameJoin, GameRead, SelfRollsUpdate
from app.schemas.game_setup import GameSetupChatRequest, GameSetupChatResponse from app.schemas.game_setup import GameSetupChatRequest, GameSetupChatResponse
from app.schemas.message import MessageRead from app.schemas.message import MessageRead
from app.ws_tickets import issue_ticket from app.ws_tickets import issue_ticket
@@ -42,17 +43,28 @@ async def _serialize_games(
participant_rows = ( participant_rows = (
await session.execute( 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) .join(User, User.id == GameParticipant.user_id)
.where(GameParticipant.game_id.in_(game_ids)) .where(GameParticipant.game_id.in_(game_ids))
) )
).all() ).all()
participants_by_game: dict[uuid.UUID, list[str]] = {} participants_by_game: dict[uuid.UUID, list[str]] = {}
viewer_participation: dict[uuid.UUID, uuid.UUID | None] = {} 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) participants_by_game.setdefault(game_id, []).append(name)
if participant_user_id == viewer_id: if participant_user_id == viewer_id:
viewer_participation[game_id] = character_id viewer_participation[game_id] = character_id
viewer_self_rolls[game_id] = self_rolls
viewer_pending_roll[game_id] = pending_roll
result = [] result = []
for game in games: for game in games:
@@ -73,6 +85,9 @@ async def _serialize_games(
my_character_id=viewer_participation.get(game.id), my_character_id=viewer_participation.get(game.id),
status=game.status, status=game.status,
ended_reason=game.ended_reason, 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 return result
@@ -98,6 +113,7 @@ async def create_game(
game = Game( game = Game(
name=payload.name, name=payload.name,
description=payload.description, description=payload.description,
adventure_text=payload.adventure_text,
creator_id=user.id, creator_id=user.id,
participation_code=_generate_participation_code(), participation_code=_generate_participation_code(),
) )
@@ -108,6 +124,9 @@ async def create_game(
await session.commit() await session.commit()
await session.refresh(game) 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) serialized = await _serialize_games(session, [game], user.id)
return serialized[0] return serialized[0]
@@ -117,7 +136,7 @@ async def game_setup_chat(
payload: GameSetupChatRequest, payload: GameSetupChatRequest,
user: User = Depends(current_active_user), user: User = Depends(current_active_user),
) -> GameSetupChatResponse: ) -> GameSetupChatResponse:
result = await run_game_setup_turn(payload.messages) result = await run_game_setup_turn(payload.messages, payload.adventure_text)
return GameSetupChatResponse(**result) return GameSetupChatResponse(**result)
@@ -134,6 +153,23 @@ async def get_game(
return serialized[0] return serialized[0]
@router.delete("/{game_id}", status_code=204)
async def delete_game(
game_id: uuid.UUID,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
) -> None:
game = await session.get(Game, game_id)
if game is None:
raise HTTPException(status_code=404, detail="Game not found")
if game.creator_id != user.id:
raise HTTPException(status_code=403, detail="Only the creator can delete this game")
# game_participants, messages, world_state, and adventure_chunks all cascade on games.id.
await session.delete(game)
await session.commit()
async def _serialize_messages(session: AsyncSession, messages: list[Message]) -> list[MessageRead]: async def _serialize_messages(session: AsyncSession, messages: list[Message]) -> list[MessageRead]:
if not messages: if not messages:
return [] return []
@@ -254,6 +290,24 @@ async def join_game(
return serialized[0] 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") @router.post("/{game_id}/ws-ticket")
async def create_ws_ticket( async def create_ws_ticket(
game_id: uuid.UUID, game_id: uuid.UUID,
+51 -24
View File
@@ -4,6 +4,7 @@ import uuid
from fastapi import APIRouter, WebSocket, WebSocketDisconnect from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import async_session_maker from app.db import async_session_maker
from app.llm.orchestrator import run_dm_turn from app.llm.orchestrator import run_dm_turn
@@ -11,6 +12,8 @@ from app.models.game import GameParticipant
from app.models.message import Message from app.models.message import Message
from app.ws_tickets import consume_ticket from app.ws_tickets import consume_ticket
KICKOFF_QUERY = "Beginne die Sitzung und begrüße die anwesenden Spieler."
logger = logging.getLogger("app.ws_game") logger = logging.getLogger("app.ws_game")
router = APIRouter() router = APIRouter()
@@ -70,6 +73,41 @@ 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, 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})
try:
dm_message = await run_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
logger.exception("DM turn failed for game %s", game_id)
await manager.broadcast(game_id, {"type": "error", "detail": "dm_turn_failed"})
return
await manager.broadcast(
game_id,
{"type": "message", "message": await _serialize_message(session, dm_message)},
)
@router.websocket("/ws/games/{game_id}") @router.websocket("/ws/games/{game_id}")
async def game_websocket(websocket: WebSocket, game_id: uuid.UUID, ticket: str) -> None: async def game_websocket(websocket: WebSocket, game_id: uuid.UUID, ticket: str) -> None:
user_id = consume_ticket(ticket, game_id) user_id = consume_ticket(ticket, game_id)
@@ -80,6 +118,18 @@ async def game_websocket(websocket: WebSocket, game_id: uuid.UUID, ticket: str)
await websocket.accept() await websocket.accept()
manager.connect(game_id, websocket) manager.connect(game_id, websocket)
# First-ever connection to a fresh game: the DM speaks first, unprompted, instead of
# waiting for a player to type something just to kick things off. Guarded by the game's
# lock + a "does this game have any messages yet" check so concurrent connections (e.g. two
# tabs opening around the same time) can't trigger this twice.
async with manager.get_lock(game_id):
async with async_session_maker() as session:
has_messages = (
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, user_id=user_id)
try: try:
while True: while True:
data = await websocket.receive_json() data = await websocket.receive_json()
@@ -118,31 +168,8 @@ async def game_websocket(websocket: WebSocket, game_id: uuid.UUID, ticket: str)
game_id, game_id,
{"type": "message", "message": await _serialize_message(session, player_message)}, {"type": "message", "message": await _serialize_message(session, player_message)},
) )
await manager.broadcast(game_id, {"type": "typing"})
async def _broadcast_roll(notation: str) -> None: await _run_and_broadcast_dm_turn(session, game_id, content, user_id=user_id)
await manager.broadcast(game_id, {"type": "rolling", "notation": notation})
async def _broadcast_game_ended(reason: str) -> None:
await manager.broadcast(game_id, {"type": "game_ended", "reason": reason})
try:
dm_message = await run_dm_turn(
session,
game_id,
latest_player_message=content,
on_roll=_broadcast_roll,
on_game_ended=_broadcast_game_ended,
)
except Exception: # noqa: BLE001
logger.exception("DM turn failed for game %s", game_id)
await manager.broadcast(game_id, {"type": "error", "detail": "dm_turn_failed"})
continue
await manager.broadcast(
game_id,
{"type": "message", "message": await _serialize_message(session, dm_message)},
)
except WebSocketDisconnect: except WebSocketDisconnect:
pass pass
finally: finally:
+5
View File
@@ -18,6 +18,11 @@ def get_game_setup_prompt() -> str:
return (PROMPTS_DIR / "game_setup_prompt.txt").read_text(encoding="utf-8") 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 @lru_cache
def get_llm_client() -> AsyncOpenAI: def get_llm_client() -> AsyncOpenAI:
if settings.llm_provider == "lmstudio": if settings.llm_provider == "lmstudio":
+41 -9
View File
@@ -1,22 +1,32 @@
import uuid import uuid
from datetime import datetime
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.character import Character from app.models.character import Character
from app.models.message import Message from app.models.message import Message
from app.models.user import User from app.models.user import User
# Rough char-based budget for Phase 1 (~4 chars/token, leaves headroom for system prompt + output). # Rough char-based budget for the raw message window (~4 chars/token). Kept fairly small now that
MAX_CONTEXT_CHARS = 40_000 # update_world_state carries older context forward — this window is just recent continuity, not
# the sole memory of the session anymore. Full history is still available via "Volltext laden".
MAX_CONTEXT_CHARS = 16_000
async def build_context(session: AsyncSession, game_id: uuid.UUID) -> list[dict]: async def _load_messages(
messages = ( session: AsyncSession, game_id: uuid.UUID, since: datetime | None = None
await session.execute( ) -> list[Message]:
select(Message).where(Message.game_id == game_id).order_by(Message.id.asc()) query = select(Message).where(Message.game_id == game_id)
) if since is not None:
).scalars().all() query = query.where(Message.created_at > since)
query = query.order_by(Message.id.asc())
return list((await session.execute(query)).scalars().all())
async def _label_messages(session: AsyncSession, messages: list[Message]) -> list[dict]:
"""Resolves player/character names and renders each message as a (role, content) entry —
the shared groundwork for both the chat-style context and the plain-text transcript."""
if not messages: if not messages:
return [] return []
@@ -45,6 +55,11 @@ async def build_context(session: AsyncSession, game_id: uuid.UUID) -> list[dict]
label = f"{player_name} ({character_name})" if character_name else player_name label = f"{player_name} ({character_name})" if character_name else player_name
entries.append({"role": "user", "content": f"{label}: {m.content}"}) entries.append({"role": "user", "content": f"{label}: {m.content}"})
# sender_type == "system" messages are not sent to the model in Phase 1 # sender_type == "system" messages are not sent to the model in Phase 1
return entries
async def build_context(session: AsyncSession, game_id: uuid.UUID) -> list[dict]:
entries = await _label_messages(session, await _load_messages(session, game_id))
# Keep the newest entries within the char budget, dropping oldest first. # Keep the newest entries within the char budget, dropping oldest first.
total = 0 total = 0
@@ -57,3 +72,20 @@ async def build_context(session: AsyncSession, game_id: uuid.UUID) -> list[dict]
kept.reverse() kept.reverse()
return kept return kept
async def count_messages_since(session: AsyncSession, game_id: uuid.UUID, since: datetime | None) -> int:
query = select(func.count()).select_from(Message).where(Message.game_id == game_id)
if since is not None:
query = query.where(Message.created_at > since)
return (await session.execute(query)).scalar_one()
async def build_transcript_text(
session: AsyncSession, game_id: uuid.UUID, since: datetime | None = None
) -> str:
"""Full, uncapped plain-text transcript (optionally only messages after `since`) — used to
feed the world-state summarizer, which needs everything new, not a char-budget window."""
entries = await _label_messages(session, await _load_messages(session, game_id, since=since))
lines = [f"DM: {e['content']}" if e["role"] == "assistant" else e["content"] for e in entries]
return "\n\n".join(lines)
+64 -5
View File
@@ -1,24 +1,74 @@
import json import json
import logging
from app.config import settings 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.json_utils import fix_double_escaped_unicode
from app.llm.tools import game_setup as game_setup_tool from app.llm.tools import game_setup as game_setup_tool
logger = logging.getLogger("app.llm.game_setup")
MAX_ROUNDS = 3 MAX_ROUNDS = 3
MAX_TOKENS = 1024 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}] TOOLS = [{"type": "function", "function": game_setup_tool.TOOL_SCHEMA}]
async def run_game_setup_turn(client_messages: list[dict]) -> dict: 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). """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, 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() client = get_llm_client()
messages = [{"role": "system", "content": get_game_setup_prompt()}] + client_messages system_prompt = get_game_setup_prompt()
if adventure_text.strip():
excerpt = adventure_text.strip()[:ADVENTURE_EXCERPT_CHARS]
system_prompt = (
f"{system_prompt}\n\nDer Spieler hat bereits ein vorhandenes Abenteuer mitgegeben "
f"(Anfang unten). Leite Genre/Welt daraus ab, statt danach zu fragen — frage nur noch, "
f"was daraus nicht hervorgeht:\n\n{excerpt}"
)
messages = [{"role": "system", "content": system_prompt}] + client_messages
assistant_text: str | None = None assistant_text: str | None = None
proposal: dict | None = None proposal: dict | None = None
@@ -53,4 +103,13 @@ async def run_game_setup_turn(client_messages: list[dict]) -> dict:
if proposal: if proposal:
break 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,
}
+145 -5
View File
@@ -3,15 +3,19 @@ import logging
import uuid import uuid
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings from app.config import settings
from app.llm.client import get_dm_system_prompt, get_llm_client from app.llm.client import get_dm_system_prompt, get_llm_client
from app.llm.context import build_context from app.llm.context import build_context
from app.llm.json_utils import fix_double_escaped_unicode from app.llm.json_utils import fix_double_escaped_unicode
from app.llm.tools import character_sheet, dice, end_game from app.llm.tools import character_sheet, dice, end_game, link_existing_character, monster, world_state
from app.models.game import Game from app.models.character import Character
from app.models.game import Game, GameParticipant
from app.models.message import Message from app.models.message import Message
from app.models.user import User
from app.rag.adventure_retrieval import build_adventure_rag_block
from app.rag.retrieval import build_rag_block from app.rag.retrieval import build_rag_block
logger = logging.getLogger("app.llm.orchestrator") logger = logging.getLogger("app.llm.orchestrator")
@@ -23,6 +27,9 @@ TOOLS = [
{"type": "function", "function": dice.TOOL_SCHEMA}, {"type": "function", "function": dice.TOOL_SCHEMA},
{"type": "function", "function": character_sheet.TOOL_SCHEMA}, {"type": "function", "function": character_sheet.TOOL_SCHEMA},
{"type": "function", "function": end_game.TOOL_SCHEMA}, {"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},
] ]
@@ -38,6 +45,12 @@ async def _execute_tool_call(
return await character_sheet.upsert(session, game_id, tool_input) return await character_sheet.upsert(session, game_id, tool_input)
if tool_name == "end_game": if tool_name == "end_game":
return await end_game.end(session, game_id, tool_input) return await end_game.end(session, game_id, tool_input)
if tool_name == "update_monster_hp":
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}"} return {"error": f"Unknown tool {tool_name!r}"}
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
logger.warning("Tool call %s failed: %s", tool_name, exc) logger.warning("Tool call %s failed: %s", tool_name, exc)
@@ -45,6 +58,57 @@ async def _execute_tool_call(
return {"error": str(exc)} 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: 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 """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.""" dropped to 0 HP or below. Returns the end reason if the game was just ended, else None."""
@@ -67,12 +131,38 @@ async def run_dm_turn(
session: AsyncSession, session: AsyncSession,
game_id: uuid.UUID, game_id: uuid.UUID,
latest_player_message: str | None = None, latest_player_message: str | None = None,
user_id: uuid.UUID | None = None,
on_roll: Callable[[str], Awaitable[None]] | 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, on_game_ended: Callable[[str], Awaitable[None]] | None = None,
) -> Message: ) -> Message:
client = get_llm_client() client = get_llm_client()
system_prompt = get_dm_system_prompt() 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) game = await session.get(Game, game_id)
if game is not None: if game is not None:
system_prompt = ( system_prompt = (
@@ -82,11 +172,53 @@ async def run_dm_turn(
f"Name: {game.name}\nBeschreibung: {game.description}" f"Name: {game.name}\nBeschreibung: {game.description}"
) )
participant_names = (
await session.execute(
select(User.name)
.join(GameParticipant, GameParticipant.user_id == User.id)
.where(GameParticipant.game_id == game_id)
)
).scalars().all()
if participant_names:
system_prompt += (
"\n\nBereits beigetretene Spieler (über Login/Einladungslink — NICHT nach Anzahl "
"oder Namen fragen): " + ", ".join(participant_names) + ". Weitere können jederzeit "
"ü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:
system_prompt = (
f"{system_prompt}\n\nBisheriger Weltzustand (automatisch/von dir über update_world_state "
f"aktuell gehalten — der Chatverlauf unten zeigt nur ein aktuelles Fenster, das hier "
f"trägt alles Ältere):\n{world_summary}"
)
if latest_player_message: if latest_player_message:
rag_block = await build_rag_block(session, latest_player_message) rag_block = await build_rag_block(session, latest_player_message)
if rag_block: if rag_block:
system_prompt = f"{system_prompt}\n\n{rag_block}" system_prompt = f"{system_prompt}\n\n{rag_block}"
adventure_block = await build_adventure_rag_block(session, game_id, latest_player_message)
if adventure_block:
system_prompt = f"{system_prompt}\n\n{adventure_block}"
messages = [{"role": "system", "content": system_prompt}] messages = [{"role": "system", "content": system_prompt}]
messages.extend(await build_context(session, game_id)) messages.extend(await build_context(session, game_id))
@@ -109,9 +241,17 @@ async def run_dm_turn(
for tool_call in message.tool_calls: for tool_call in message.tool_calls:
tool_input = fix_double_escaped_unicode(json.loads(tool_call.function.arguments)) 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", "")) if tool_call.function.name == "roll_dice" and participant is not None and participant.self_rolls:
result = await _execute_tool_call(session, game_id, tool_call.function.name, tool_input) 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 end_reason = None
if tool_call.function.name == "end_game" and "reason" in result: 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.
+12 -10
View File
@@ -1,15 +1,14 @@
Rolle Rolle
Du bist ein erfahrener D&D Dungeon Master und Regelexperte (Standard: D&D 5e, sofern nichts anderes vereinbart wird). Du erschaffst lebendige Abenteuer, leitest die Spieler souverän durch die Sitzung und sorgst für ein rundes Spielerlebnis – von der ersten Frage bis zum Abspann der Session. Du bist ein erfahrener D&D Dungeon Master und Regelexperte (Standard: D&D 5e, sofern nichts anderes vereinbart wird). Du erschaffst lebendige Abenteuer, leitest die Spieler souverän durch die Sitzung und sorgst für ein rundes Spielerlebnis – von der ersten Frage bis zum Abspann der Session.
Phase 1: Session 0 (Vorbereitung) Phase 1: Session 0 (Vorbereitung)
Bevor das Abenteuer beginnt, 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) – Punkte, die dort schon beantwortet sind (z. B. Genre/Welt, Umfang, ggf. Erfahrungsstand), fragst du NICHT erneut ab, sondern bestätigst sie höchstens knapp im Gespräch. Stelle nur zu den noch offenen Punkten gezielt Fragen, eine nach der anderen, nicht alle auf einmal: 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. Anzahl der Spieler und ihre Vornamen. 1. Erfahrungsstand: Komplette Anfänger, Gelegenheitsspieler oder Regelkenner? Frage das pro Spieler ab, falls die Gruppe gemischt ist.
2. Erfahrungsstand: Komplette Anfänger, Gelegenheitsspieler oder Regelkenner? Frage das pro Spieler ab, falls die Gruppe gemischt ist. 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. Charaktere: Wollen sie eigene, fertige Charaktere mitbringen (Name, Klasse, Rasse, Stats) oder sollst du sie durch die Charaktererstellung führen? 3. Regelkenntnis: Brauchen sie Erklärungen zu Grundmechaniken (Würfelwürfe, Angriffe, Rettungswürfe) oder reicht ein knapper Hinweis?
4. 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. 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?
6. Umfang: Einzelnes One-Shot-Abenteuer (eine Sitzung) oder eine längere Kampagne über mehrere Sitzungen? Grobe Zeit, die sie investieren wollen? 6. Inhaltliche Grenzen: Gibt es Themen, die sie im Spiel nicht wollen (Triggerwarnungen, Gewaltlevel, etc.)?
7. Inhaltliche Grenzen: Gibt es Themen, die sie im Spiel nicht wollen (Triggerwarnungen, Gewaltlevel, etc.)?
Passe deinen Hilfe-Level dynamisch an: Anfängern erklärst du Mechaniken proaktiv und schlägst Optionen vor ("Du kannst jetzt A, B oder C tun"). Erfahrenen Spielern gibst du nur auf Nachfrage Regeldetails und lässt ihnen mehr erzählerische Freiheit. Passe deinen Hilfe-Level dynamisch an: Anfängern erklärst du Mechaniken proaktiv und schlägst Optionen vor ("Du kannst jetzt A, B oder C tun"). Erfahrenen Spielern gibst du nur auf Nachfrage Regeldetails und lässt ihnen mehr erzählerische Freiheit.
Phase 2: Das Spiel leiten Phase 2: Das Spiel leiten
@@ -20,10 +19,12 @@ 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. - 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. - 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. - 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. * 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. * 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.
* Behalte den Weltzustand im Blick: NPC-Namen, Orte, offene Handlungsstränge, Inventar und Fortschritt der Charaktere bleiben über die Sitzung(en) hinweg konsistent. * Weltzustand: Der Chatverlauf, den du siehst, ist nur ein begrenztes aktuelles Fenster – ältere Nachrichten fallen irgendwann heraus. Halte deshalb über update_world_state eine kompakte, aktuelle Zusammenfassung fest (wichtige NPCs und ihr Status, aktueller Ort, offene Handlungsstränge, Party-/Inventarstand). Ruf es regelmäßig auf, mindestens bei jedem Szenenwechsel oder bedeutendem Ereignis – nicht erst am Ende. Schreibe jedes Mal die vollständige aktuelle Zusammenfassung, kein Diff.
* Baue die Sitzung dramaturgisch auf: Einstieg, ansteigende Spannung, Höhepunkt, Auflösung – bei Kampagnen jede Sitzung mit einem Cliffhanger oder klaren Abschluss beenden. * Baue die Sitzung dramaturgisch auf: Einstieg, ansteigende Spannung, Höhepunkt, Auflösung – bei Kampagnen jede Sitzung mit einem Cliffhanger oder klaren Abschluss beenden.
* Im Spiel sprichst du im Ton und Slang passend zur Welt und den Charakteren (z. B. archaisch-episch in High Fantasy, derb-direkt bei Piraten). Außerhalb bleibst du locker, freundlich und klar in der Ansprache. * Im Spiel sprichst du im Ton und Slang passend zur Welt und den Charakteren (z. B. archaisch-episch in High Fantasy, derb-direkt bei Piraten). Außerhalb bleibst du locker, freundlich und klar in der Ansprache.
@@ -32,6 +33,7 @@ Regeln & Wissensquellen
* Du bist Regelexperte: Bei Unsicherheiten der Spieler erklärst du Mechaniken (Kämpfe, Zauber, Fertigkeiten, Zustände) verständlich und korrekt. * Du bist Regelexperte: Bei Unsicherheiten der Spieler erklärst du Mechaniken (Kämpfe, Zauber, Fertigkeiten, Zustände) verständlich und korrekt.
* Wenn Handbücher/Regelwerke hochgeladen wurden, richtest du dich strikt nach deren Inhalt und zitierst/nennst bei Bedarf die relevante Regel. Wurde nichts hochgeladen, greifst du auf dein allgemeines D&D-5e-Regelwissen zurück und weist transparent darauf hin, falls du bei einer Detailregel unsicher bist, statt sie zu erfinden. * Wenn Handbücher/Regelwerke hochgeladen wurden, richtest du dich strikt nach deren Inhalt und zitierst/nennst bei Bedarf die relevante Regel. Wurde nichts hochgeladen, greifst du auf dein allgemeines D&D-5e-Regelwissen zurück und weist transparent darauf hin, falls du bei einer Detailregel unsicher bist, statt sie zu erfinden.
* Bei Widersprüchen zwischen Spielspaß und exakter Regelauslegung fragst du kurz nach, welche Priorität die Gruppe bevorzugt (z. B. eher erzählerisch-locker oder regelgenau). * Bei Widersprüchen zwischen Spielspaß und exakter Regelauslegung fragst du kurz nach, welche Priorität die Gruppe bevorzugt (z. B. eher erzählerisch-locker oder regelgenau).
* Stehen unten Auszüge aus einem vorgegebenen Abenteuer, hält sich die Handlung strikt daran – Ort, NPCs, Encounter, Plot so, wie im Text vorgegeben, statt eigene zu erfinden. Weiche nur ab, wenn die Spieler klar einen eigenen Weg einschlagen oder der Text an der Stelle keine Vorgabe macht; führe dann sinnvoll weiter und kehre zum Abenteuer zurück, sobald es passt.
Sprache Sprache
Antworte immer in der Sprache, in der die Spieler mit dir kommunizieren. Wechselt die Gruppe die Sprache, wechselst du mit. Antworte immer in der Sprache, in der die Spieler mit dir kommunizieren. Wechselt die Gruppe die Sprache, wechselst du mit.
@@ -8,7 +8,9 @@ Stelle dem Spieler nacheinander (eine Frage nach der anderen, nicht alle auf ein
2. Wie lang soll das Spiel werden — ein einzelnes One-Shot-Abenteuer oder eine längere Kampagne über mehrere Sitzungen? 2. Wie lang soll das Spiel werden — ein einzelnes One-Shot-Abenteuer oder eine längere Kampagne über mehrere Sitzungen?
3. Wie ist sein Erfahrungsstand mit D&D — komplette(r) Anfänger(in), gelegentlich gespielt, oder erfahrene(r) Regelkenner(in)? 3. Wie ist sein Erfahrungsstand mit D&D — komplette(r) Anfänger(in), gelegentlich gespielt, oder erfahrene(r) Regelkenner(in)?
Sobald du alle drei Antworten hast, rufe das Werkzeug propose_game_setup auf mit: Wurde dir bereits ein vorhandenes Abenteuer mitgegeben (siehe unten), leite Genre/Welt und ggf. den Umfang direkt daraus ab und frage dazu nicht extra nach — stelle nur noch die Fragen, deren Antwort sich nicht aus dem Text ergibt.
Sobald du alle drei Antworten hast (oder aus dem mitgegebenen Abenteuer kennst), rufe das Werkzeug propose_game_setup auf mit:
- einem passenden, einprägsamen Namen für das Spiel - einem passenden, einprägsamen Namen für das Spiel
- einer kurzen Beschreibung (Genre und Welt, optional ein Hinweis auf Umfang und Erfahrungsstand) - einer kurzen Beschreibung (Genre und Welt, optional ein Hinweis auf Umfang und Erfahrungsstand)
+13 -2
View File
@@ -9,7 +9,9 @@ TOOL_SCHEMA = {
"name": "roll_dice", "name": "roll_dice",
"description": ( "description": (
"Roll dice using standard D&D notation (e.g. '1d20+5', '2d6', 'd8-1'). " "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": { "parameters": {
"type": "object", "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) match = _NOTATION_RE.match(notation)
if not match: if not match:
raise ValueError(f"Invalid dice notation: {notation!r}") raise ValueError(f"Invalid dice notation: {notation!r}")
@@ -44,6 +49,12 @@ def roll(notation: str) -> dict:
if sign == "-": if sign == "-":
modifier = -modifier 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)] rolls = [_rng.randint(1, sides) for _ in range(count)]
total = sum(rolls) + modifier 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,
}
+50
View File
@@ -0,0 +1,50 @@
import uuid
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.game import Game
TOOL_SCHEMA = {
"name": "update_monster_hp",
"description": (
"Track an NPC's/monster's hit points during a fight — call this whenever one is introduced "
"or takes damage, so the server can detect when it's defeated. This is DM-only bookkeeping, "
"never shown to players; keep narrating combat and secrecy (e.g. AC) exactly as before."
),
"parameters": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "The monster's/NPC's name, used consistently across calls to identify it.",
},
"current_hp": {"type": "integer"},
"max_hp": {"type": "integer"},
},
"required": ["name", "current_hp", "max_hp"],
},
}
async def update(session: AsyncSession, game_id: uuid.UUID, tool_input: dict) -> dict:
game = await session.get(Game, game_id)
if game is None:
raise ValueError(f"Game {game_id} not found")
name = tool_input["name"]
current_hp = tool_input["current_hp"]
max_hp = tool_input["max_hp"]
# Build an entirely new list of new dicts rather than mutating existing ones in place:
# SQLAlchemy compares the old and new JSONB value by content to decide whether a flush is
# needed, and an in-place mutation makes the "old" value (still referenced by the attribute)
# look identical to the "new" one by the time of that comparison, so the UPDATE silently
# gets skipped.
updated_entry = {"name": name, "current_hp": current_hp, "max_hp": max_hp}
monsters = [dict(m) for m in (game.monsters or []) if m.get("name") != name]
monsters.append(updated_entry)
game.monsters = monsters
await session.commit()
return {"name": name, "current_hp": current_hp, "max_hp": max_hp, "defeated": current_hp <= 0}
+112
View File
@@ -0,0 +1,112 @@
import logging
import uuid
from sqlalchemy.ext.asyncio import AsyncSession
from app.llm.context import build_transcript_text, count_messages_since
from app.models.world_state import WorldState
logger = logging.getLogger("app.llm.tools.world_state")
# After this many new messages without an update_world_state call, force a dedicated
# summarization call instead of relying on the DM to remember — see run_dm_turn().
AUTO_REFRESH_MESSAGE_THRESHOLD = 10
SUMMARIZER_SYSTEM_PROMPT = (
"Du fasst den bisherigen Verlauf eines laufenden D&D-Text-Adventures kompakt zusammen. "
"Antworte NUR mit der Zusammenfassung selbst — keine Einleitung, keine Meta-Kommentare. "
"Schreibe in der Sprache des Transkripts."
)
TOOL_SCHEMA = {
"name": "update_world_state",
"description": (
"Persist a compact, up-to-date recap of everything that matters long-term for this game: "
"key NPCs and their status, current location, open plot threads, important party/inventory "
"state. The raw chat history you see is only a recent window — this summary is what carries "
"everything older forward. Call it regularly (at least on every scene change or major "
"development), not just at the end. Send the full current picture each time, not a diff — "
"this replaces whatever was stored before."
),
"parameters": {
"type": "object",
"properties": {
"summary": {
"type": "string",
"description": (
"The complete current world-state recap, in the language you're playing in. "
"A few short paragraphs or a bullet list, not a full transcript."
),
},
},
"required": ["summary"],
},
}
async def update(session: AsyncSession, game_id: uuid.UUID, tool_input: dict) -> dict:
state = await session.get(WorldState, game_id)
if state is None:
state = WorldState(game_id=game_id, summary=tool_input["summary"])
session.add(state)
else:
state.summary = tool_input["summary"]
await session.commit()
return {"summary": state.summary}
async def get_summary(session: AsyncSession, game_id: uuid.UUID) -> str | None:
state = await session.get(WorldState, game_id)
return state.summary if state is not None and state.summary else None
async def refresh_if_due(session: AsyncSession, game_id: uuid.UUID, client, model: str) -> bool:
"""Hard backstop for update_world_state: if AUTO_REFRESH_MESSAGE_THRESHOLD new messages have
piled up since the summary was last touched (by the DM's tool call or a prior auto-refresh),
force a dedicated summarization call instead of trusting the DM to remember. Returns whether
it actually refreshed."""
state = await session.get(WorldState, game_id)
since = state.updated_at if state is not None else None
new_count = await count_messages_since(session, game_id, since)
if new_count < AUTO_REFRESH_MESSAGE_THRESHOLD:
return False
transcript = await build_transcript_text(session, game_id, since=since)
if not transcript:
return False
prior_summary = state.summary if state is not None and state.summary else "(noch keine)"
user_prompt = (
f"Bisherige Zusammenfassung:\n{prior_summary}\n\n"
f"Neue Ereignisse seither:\n{transcript}\n\n"
"Schreibe die vollständige, aktuelle Zusammenfassung neu (nicht nur die neuen Ereignisse "
"anhängen): wichtige NPCs und ihr Status, aktueller Ort, offene Handlungsstränge, "
"Party-/Inventarstand. Ein paar kurze Absätze oder Stichpunkte, kein Transkript."
)
try:
response = await client.chat.completions.create(
model=model,
max_tokens=800,
messages=[
{"role": "system", "content": SUMMARIZER_SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
)
summary = response.choices[0].message.content
except Exception as exc: # noqa: BLE001
logger.warning("Auto world-state summarization failed for game %s: %s", game_id, exc)
return False
if not summary:
return False
if state is None:
state = WorldState(game_id=game_id, summary=summary)
session.add(state)
else:
state.summary = summary
await session.commit()
return True
+12 -1
View File
@@ -1,7 +1,18 @@
from app.models.adventure_chunk import AdventureChunk
from app.models.character import Character from app.models.character import Character
from app.models.game import Game, GameParticipant from app.models.game import Game, GameParticipant
from app.models.message import Message from app.models.message import Message
from app.models.rulebook_chunk import RulebookChunk from app.models.rulebook_chunk import RulebookChunk
from app.models.user import User from app.models.user import User
from app.models.world_state import WorldState
__all__ = ["User", "Game", "GameParticipant", "Character", "Message", "RulebookChunk"] __all__ = [
"User",
"Game",
"GameParticipant",
"Character",
"Message",
"RulebookChunk",
"WorldState",
"AdventureChunk",
]
+23
View File
@@ -0,0 +1,23 @@
import uuid
from pgvector.sqlalchemy import Vector
from sqlalchemy import ForeignKey, Integer, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db import Base
from app.models.rulebook_chunk import EMBEDDING_DIM
class AdventureChunk(Base):
__tablename__ = "adventure_chunks"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
game_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("games.id", ondelete="CASCADE"), nullable=False, index=True
)
chunk_index: Mapped[int] = mapped_column(Integer, nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)
embedding: Mapped[list[float]] = mapped_column(Vector(EMBEDDING_DIM), nullable=False)
+13 -1
View File
@@ -2,7 +2,7 @@ import uuid
from datetime import datetime from datetime import datetime
from sqlalchemy import ForeignKey, String, Text, UniqueConstraint, func from sqlalchemy import ForeignKey, String, Text, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.db import Base from app.db import Base
@@ -24,6 +24,12 @@ class Game(Base):
) )
status: Mapped[str] = mapped_column(String(length=20), nullable=False, default="active") status: Mapped[str] = mapped_column(String(length=20), nullable=False, default="active")
ended_reason: Mapped[str | None] = mapped_column(Text, nullable=True) ended_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
# DM-only bookkeeping (name/current_hp/max_hp per monster) — never serialized to any
# API response, since HP/AC of NPCs is meant to stay secret from players.
monsters: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
# Raw pasted adventure-module text (if any) — chunked/embedded into adventure_chunks for
# retrieval; kept here verbatim as the source of truth for future re-ingestion.
adventure_text: Mapped[str] = mapped_column(Text, nullable=False, default="")
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
server_default=func.now(), nullable=False, index=True server_default=func.now(), nullable=False, index=True
) )
@@ -45,4 +51,10 @@ class GameParticipant(Base):
character_id: Mapped[uuid.UUID | None] = mapped_column( character_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("characters.id"), nullable=True 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) joined_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False)
+20
View File
@@ -0,0 +1,20 @@
import uuid
from datetime import datetime
from sqlalchemy import ForeignKey, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db import Base
class WorldState(Base):
__tablename__ = "world_state"
game_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("games.id", ondelete="CASCADE"), primary_key=True
)
summary: Mapped[str] = mapped_column(Text, nullable=False, default="")
updated_at: Mapped[datetime] = mapped_column(
server_default=func.now(), onupdate=func.now(), nullable=False
)
+40
View File
@@ -0,0 +1,40 @@
import asyncio
import uuid
from sqlalchemy import delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.adventure_chunk import AdventureChunk
from app.rag.chunking import chunk_text
from app.rag.embeddings import embed_texts
EMBED_BATCH_SIZE = 32
async def ingest_adventure_text(session: AsyncSession, game_id: uuid.UUID, text: str) -> int:
"""Chunks and embeds a player-supplied adventure text, scoped to one game. Safe to call
again for the same game (e.g. future re-ingest) — previous chunks are cleared first."""
await session.execute(delete(AdventureChunk).where(AdventureChunk.game_id == game_id))
chunks = chunk_text(text)
if not chunks:
await session.commit()
return 0
for batch_start in range(0, len(chunks), EMBED_BATCH_SIZE):
batch = chunks[batch_start : batch_start + EMBED_BATCH_SIZE]
# embed_texts is a synchronous, CPU-bound sentence-transformers call — run it off the
# event loop so a large adventure doesn't stall other concurrent requests/WS connections.
embeddings = await asyncio.to_thread(embed_texts, batch)
for i, (content, embedding) in enumerate(zip(batch, embeddings)):
session.add(
AdventureChunk(
game_id=game_id,
chunk_index=batch_start + i,
content=content,
embedding=embedding,
)
)
await session.commit()
return len(chunks)
+58
View File
@@ -0,0 +1,58 @@
import asyncio
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.adventure_chunk import AdventureChunk
from app.rag.embeddings import embed_query
DEFAULT_TOP_K = 5
# Same calibration as the rulebook RAG (app/rag/retrieval.py) — see there for how these were chosen.
MAX_DISTANCE = 0.6
MAX_BLOCK_CHARS = 4000
async def build_adventure_rag_block(
session: AsyncSession, game_id: uuid.UUID, query: str, top_k: int = DEFAULT_TOP_K
) -> str:
# Chunk 0 (the adventure's opening) is always included alongside the similarity search:
# on the very first turns the player's message is often just answering Session-0 questions
# and won't necessarily be similar to the adventure's actual hook, so pure similarity search
# could miss the beginning entirely and leave the DM to improvise instead of following it.
opening = (
await session.execute(
select(AdventureChunk)
.where(AdventureChunk.game_id == game_id, AdventureChunk.chunk_index == 0)
)
).scalar_one_or_none()
query_embedding = await asyncio.to_thread(embed_query, query)
distance = AdventureChunk.embedding.cosine_distance(query_embedding)
rows = (
await session.execute(
select(AdventureChunk, distance.label("distance"))
.where(AdventureChunk.game_id == game_id)
.order_by(distance)
.limit(top_k)
)
).all()
relevant = [chunk for chunk, dist in rows if dist <= MAX_DISTANCE]
if opening is not None and opening.id not in {c.id for c in relevant}:
relevant.insert(0, opening)
if not relevant:
return ""
parts = [
"Auszüge aus dem vorgegebenen Abenteuer — halte dich strikt daran (siehe Systemanweisung):"
]
used_chars = 0
for chunk in relevant:
entry = f"\n{chunk.content}"
if used_chars + len(entry) > MAX_BLOCK_CHARS and used_chars > 0:
break
parts.append(entry)
used_chars += len(entry)
return "\n".join(parts)
+8
View File
@@ -7,6 +7,7 @@ from pydantic import BaseModel
class GameCreate(BaseModel): class GameCreate(BaseModel):
name: str name: str
description: str = "" description: str = ""
adventure_text: str = ""
class GameRead(BaseModel): class GameRead(BaseModel):
@@ -23,8 +24,15 @@ class GameRead(BaseModel):
my_character_id: uuid.UUID | None my_character_id: uuid.UUID | None
status: str status: str
ended_reason: str | None ended_reason: str | None
has_adventure: bool
my_self_rolls: bool
my_pending_roll: dict | None
class GameJoin(BaseModel): class GameJoin(BaseModel):
participation_code: str participation_code: str
character_id: uuid.UUID | None = None character_id: uuid.UUID | None = None
class SelfRollsUpdate(BaseModel):
self_rolls: bool
+2
View File
@@ -3,6 +3,7 @@ from pydantic import BaseModel
class GameSetupChatRequest(BaseModel): class GameSetupChatRequest(BaseModel):
messages: list[dict] = [] messages: list[dict] = []
adventure_text: str = ""
class GameSetupProposal(BaseModel): class GameSetupProposal(BaseModel):
@@ -14,3 +15,4 @@ class GameSetupChatResponse(BaseModel):
messages: list[dict] messages: list[dict]
assistant_text: str | None assistant_text: str | None
proposal: GameSetupProposal | None proposal: GameSetupProposal | None
generated_adventure: str | None = None
+5 -5
View File
@@ -1,13 +1,13 @@
fastapi==0.115.6 fastapi==0.141.1
uvicorn[standard]==0.34.0 uvicorn[standard]==0.34.0
sqlalchemy[asyncio]==2.0.36 sqlalchemy[asyncio]==2.0.36
asyncpg==0.30.0 asyncpg==0.30.0
alembic==1.14.0 alembic==1.14.0
pydantic-settings==2.7.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 openai>=1.50.0,<2.0.0
aiosmtplib==3.0.2 aiosmtplib==5.1.2
python-multipart==0.0.20 python-multipart==0.0.32
websockets==14.1 websockets==14.1
pgvector==0.3.6 pgvector==0.3.6
sentence-transformers==3.3.1 sentence-transformers==6.0.1
+8
View File
@@ -27,5 +27,13 @@ services:
ports: ports:
- "8000:8000" - "8000:8000"
frontend:
build: ./frontend
restart: unless-stopped
depends_on:
- backend
ports:
- "8080:80"
volumes: volumes:
pgdata: pgdata:
+54
View File
@@ -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.
+3
View File
@@ -0,0 +1,3 @@
node_modules
dist
.git
+6 -4
View File
@@ -1,9 +1,11 @@
FROM node:22-slim AS build FROM node:22-slim AS build
WORKDIR /app WORKDIR /app
COPY package.json . COPY package.json package-lock.json .
RUN npm install RUN npm ci
COPY . . COPY . .
RUN npm run build RUN npm run build
FROM scratch AS dist FROM nginx:1.27-alpine
COPY --from=build /app/dist / COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
+48
View File
@@ -0,0 +1,48 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Same-origin as far as the browser is concerned — api/client.ts calls relative paths, so
# this proxies them to the backend container internally instead of relying on CORS.
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /auth/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /users/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /health {
proxy_pass http://backend:8000;
}
location /ws/ {
proxy_pass http://backend:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 3600s;
}
# SPA fallback — client-side routes (e.g. /games/<id>) must resolve to index.html.
location / {
try_files $uri /index.html;
}
}
+3
View File
@@ -26,3 +26,6 @@ export interface Character {
export const listCharacters = () => apiFetch<Character[]>("/api/characters"); export const listCharacters = () => apiFetch<Character[]>("/api/characters");
export const getCharacter = (id: string) => apiFetch<Character>(`/api/characters/${id}`); export const getCharacter = (id: string) => apiFetch<Character>(`/api/characters/${id}`);
export const deleteCharacter = (id: string) =>
apiFetch<void>(`/api/characters/${id}`, { method: "DELETE" });
+23 -4
View File
@@ -14,6 +14,9 @@ export interface Game {
my_character_id: string | null; my_character_id: string | null;
status: "active" | "ended"; status: "active" | "ended";
ended_reason: string | null; ended_reason: string | null;
has_adventure: boolean;
my_self_rolls: boolean;
my_pending_roll: { notation: string; reason: string } | null;
} }
export interface Message { export interface Message {
@@ -36,22 +39,38 @@ export interface GameSetupChatResponse {
messages: Record<string, unknown>[]; messages: Record<string, unknown>[];
assistant_text: string | null; assistant_text: string | null;
proposal: GameSetupProposal | null; proposal: GameSetupProposal | null;
generated_adventure: string | null;
} }
export const listGames = () => apiFetch<Game[]>("/api/games"); export const listGames = () => apiFetch<Game[]>("/api/games");
export const runGameSetupChat = (messages: Record<string, unknown>[], signal?: AbortSignal) => export const runGameSetupChat = (
messages: Record<string, unknown>[],
adventureText?: string,
signal?: AbortSignal,
) =>
apiFetch<GameSetupChatResponse>("/api/games/setup-chat", { apiFetch<GameSetupChatResponse>("/api/games/setup-chat", {
method: "POST", method: "POST",
body: JSON.stringify({ messages }), body: JSON.stringify({ messages, adventure_text: adventureText ?? "" }),
signal, signal,
}); });
export const createGame = (name: string, description: string) => export const createGame = (name: string, description: string, adventureText?: string) =>
apiFetch<Game>("/api/games", { method: "POST", body: JSON.stringify({ name, description }) }); apiFetch<Game>("/api/games", {
method: "POST",
body: JSON.stringify({ name, description, adventure_text: adventureText ?? "" }),
});
export const getGame = (id: string) => apiFetch<Game>(`/api/games/${id}`); 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) => export const joinGame = (id: string, participation_code: string, character_id?: string) =>
apiFetch<Game>(`/api/games/${id}/join`, { apiFetch<Game>(`/api/games/${id}/join`, {
method: "POST", method: "POST",
+53 -10
View File
@@ -1,17 +1,60 @@
import { useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Character } from "../api/characters"; import { Character } from "../api/characters";
import { useAuth } from "../hooks/useAuth";
import ConfirmDialog from "./ConfirmDialog";
export default function CharacterCard({
character,
onDelete,
}: {
character: Character;
onDelete?: (id: string) => void;
}) {
const { user } = useAuth();
const [confirming, setConfirming] = useState(false);
const isOwner = user?.id === character.owner_id;
function handleDeleteClick(e: React.MouseEvent) {
e.preventDefault();
e.stopPropagation();
setConfirming(true);
}
export default function CharacterCard({ character }: { character: Character }) {
return ( return (
<Link <>
to={`/characters/${character.id}`} <Link
className="block rounded-lg border border-slate-800 bg-slate-900 p-4 hover:border-amber-500" to={`/characters/${character.id}`}
> className="block rounded-lg border border-slate-800 bg-slate-900 p-4 hover:border-amber-500"
<h2 className="text-lg font-semibold text-slate-100">{character.name}</h2> >
<p className="text-sm text-slate-400"> <div className="flex items-start justify-between gap-4">
{character.race ?? "?"} · {character.char_class ?? "?"} · Stufe {character.level} <h2 className="text-lg font-semibold text-slate-100">{character.name}</h2>
</p> {isOwner && onDelete && (
</Link> <button
onClick={handleDeleteClick}
className="shrink-0 rounded px-2 py-1 text-xs text-red-400 hover:bg-red-500/10 hover:text-red-300"
>
Löschen
</button>
)}
</div>
<p className="text-sm text-slate-400">
{character.race ?? "?"} · {character.char_class ?? "?"} · Stufe {character.level}
</p>
</Link>
<ConfirmDialog
open={confirming}
title="Charakter löschen?"
message={`„${character.name}" wird endgültig gelöscht. Spiele, in denen der Charakter mitgespielt hat, bleiben erhalten, verlieren aber die Verknüpfung zu ihm.`}
confirmLabel="Endgültig löschen"
onCancel={() => setConfirming(false)}
onConfirm={() => {
setConfirming(false);
onDelete?.(character.id);
}}
/>
</>
); );
} }
+46
View File
@@ -0,0 +1,46 @@
export default function ConfirmDialog({
open,
title,
message,
confirmLabel = "Löschen",
onConfirm,
onCancel,
}: {
open: boolean;
title: string;
message: string;
confirmLabel?: string;
onConfirm: () => void;
onCancel: () => void;
}) {
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
onClick={onCancel}
>
<div
className="w-full max-w-sm rounded-lg border border-slate-700 bg-slate-900 p-5"
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-lg font-semibold text-slate-100">{title}</h2>
<p className="mt-2 text-sm text-slate-400">{message}</p>
<div className="mt-5 flex justify-end gap-2">
<button
onClick={onCancel}
className="rounded bg-slate-800 px-3 py-2 text-sm hover:bg-slate-700"
>
Abbrechen
</button>
<button
onClick={onConfirm}
className="rounded bg-red-600 px-3 py-2 text-sm font-semibold text-white hover:bg-red-500"
>
{confirmLabel}
</button>
</div>
</div>
</div>
);
}
+94 -27
View File
@@ -1,37 +1,104 @@
import { useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Game } from "../api/games"; import { Game } from "../api/games";
import { useAuth } from "../hooks/useAuth";
import ConfirmDialog from "./ConfirmDialog";
export default function GameCard({ game }: { game: Game }) { export default function GameCard({
game,
onDelete,
}: {
game: Game;
onDelete?: (id: string) => void;
}) {
const { user } = useAuth();
const created = new Date(game.created_at).toLocaleString("de-DE"); const created = new Date(game.created_at).toLocaleString("de-DE");
const [copied, setCopied] = useState(false);
const [confirming, setConfirming] = useState(false);
const isOwner = user?.id === game.creator_id;
async function handleCopyLink(e: React.MouseEvent) {
e.preventDefault();
e.stopPropagation();
const link = `${window.location.origin}/games/${game.id}?code=${game.participation_code}`;
await navigator.clipboard.writeText(link);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}
function handleDeleteClick(e: React.MouseEvent) {
e.preventDefault();
e.stopPropagation();
setConfirming(true);
}
return ( return (
<Link <>
to={`/games/${game.id}`} <Link
className="block rounded-lg border border-slate-800 bg-slate-900 p-4 hover:border-amber-500" to={`/games/${game.id}`}
> className="block rounded-lg border border-slate-800 bg-slate-900 p-4 hover:border-amber-500"
<div className="flex items-start justify-between gap-4"> >
<h2 className="text-lg font-semibold text-slate-100">{game.name}</h2> <div className="flex items-start justify-between gap-4">
{game.status === "ended" && ( <h2 className="text-lg font-semibold text-slate-100">{game.name}</h2>
<span className="shrink-0 rounded bg-slate-700 px-2 py-1 text-xs font-semibold text-slate-300"> {game.status === "ended" && (
Beendet <span className="shrink-0 rounded bg-slate-700 px-2 py-1 text-xs font-semibold text-slate-300">
Beendet
</span>
)}
{game.participation_code && (
<button
onClick={handleCopyLink}
title="Einladungslink kopieren"
className="flex shrink-0 items-center gap-1.5 rounded bg-amber-500/20 px-2 py-1 font-mono text-xs text-amber-300 hover:bg-amber-500/30"
>
Code: {game.participation_code}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
className="h-3.5 w-3.5"
>
<rect x="9" y="9" width="11" height="11" rx="1.5" />
<path d="M5 15V5a2 2 0 0 1 2-2h10" />
</svg>
{copied && <span className="text-emerald-400">Kopiert!</span>}
</button>
)}
</div>
<p className="mt-1 text-sm text-slate-400">{game.description}</p>
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-slate-500">
<span>Erstellt: {created}</span>
<span>Von: {game.creator_name}</span>
<span>
{game.player_count} Spieler
{game.player_names.length > 0 && `: ${game.player_names.join(", ")}`}
</span> </span>
)} {game.has_adventure && <span>📖 Eigenes Abenteuer</span>}
{game.participation_code && ( {isOwner && onDelete && (
<span className="shrink-0 rounded bg-amber-500/20 px-2 py-1 font-mono text-xs text-amber-300"> <button
Code: {game.participation_code} onClick={handleDeleteClick}
</span> className="ml-auto rounded px-2 py-1 text-red-400 hover:bg-red-500/10 hover:text-red-300"
)} >
</div> Löschen
<p className="mt-1 text-sm text-slate-400">{game.description}</p> </button>
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-500"> )}
<span>Erstellt: {created}</span> </div>
<span>Von: {game.creator_name}</span> </Link>
<span>
{game.player_count} Spieler <ConfirmDialog
{game.player_names.length > 0 && `: ${game.player_names.join(", ")}`} open={confirming}
</span> title="Spiel löschen?"
</div> message={`„${game.name}" wird endgültig gelöscht, inklusive des gesamten Chatverlaufs. Das kann nicht rückgängig gemacht werden.`}
</Link> confirmLabel="Endgültig löschen"
onCancel={() => setConfirming(false)}
onConfirm={() => {
setConfirming(false);
onDelete?.(game.id);
}}
/>
</>
); );
} }
+60 -7
View File
@@ -10,12 +10,15 @@ interface DisplayTurn {
} }
export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) => void }) { export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) => void }) {
const [step, setStep] = useState<"adventure" | "chat">("adventure");
const [adventureText, setAdventureText] = useState("");
const [rawMessages, setRawMessages] = useState<Record<string, unknown>[]>([]); const [rawMessages, setRawMessages] = useState<Record<string, unknown>[]>([]);
const [turns, setTurns] = useState<DisplayTurn[]>([]); const [turns, setTurns] = useState<DisplayTurn[]>([]);
const [proposal, setProposal] = useState<GameSetupProposal | null>(null); const [proposal, setProposal] = useState<GameSetupProposal | null>(null);
const [generatedAdventure, setGeneratedAdventure] = useState<string | null>(null);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(false);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const bottomRef = useRef<HTMLDivElement>(null); const bottomRef = useRef<HTMLDivElement>(null);
@@ -24,6 +27,7 @@ export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) =
messages: Record<string, unknown>[]; messages: Record<string, unknown>[];
assistant_text: string | null; assistant_text: string | null;
proposal: GameSetupProposal | null; proposal: GameSetupProposal | null;
generated_adventure?: string | null;
}) { }) {
setRawMessages(res.messages); setRawMessages(res.messages);
if (res.assistant_text) { if (res.assistant_text) {
@@ -40,14 +44,18 @@ export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) =
]); ]);
} }
} }
if (res.generated_adventure) {
setGeneratedAdventure(res.generated_adventure);
}
} }
useEffect(() => { useEffect(() => {
if (step !== "chat") return;
const controller = new AbortController(); const controller = new AbortController();
(async () => { (async () => {
setLoading(true); setLoading(true);
try { try {
const res = await runGameSetupChat([], controller.signal); const res = await runGameSetupChat([], adventureText, controller.signal);
applyResponse(res); applyResponse(res);
} catch (err) { } catch (err) {
if (!(err instanceof DOMException && err.name === "AbortError")) throw err; if (!(err instanceof DOMException && err.name === "AbortError")) throw err;
@@ -59,7 +67,7 @@ export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) =
controller.abort(); controller.abort();
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, [step]);
useEffect(() => { useEffect(() => {
bottomRef.current?.scrollIntoView({ block: "end" }); bottomRef.current?.scrollIntoView({ block: "end" });
@@ -68,7 +76,7 @@ export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) =
async function handleSend(content: string) { async function handleSend(content: string) {
setTurns((prev) => [...prev, { role: "user", content }]); setTurns((prev) => [...prev, { role: "user", content }]);
const nextMessages = [...rawMessages, { role: "user", content }]; const nextMessages = [...rawMessages, { role: "user", content }];
applyResponse(await runGameSetupChat(nextMessages)); applyResponse(await runGameSetupChat(nextMessages, adventureText));
} }
async function handleCreate(e: FormEvent) { async function handleCreate(e: FormEvent) {
@@ -76,7 +84,7 @@ export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) =
setCreating(true); setCreating(true);
setError(null); setError(null);
try { try {
const game = await createGame(name, description); const game = await createGame(name, description, adventureText || generatedAdventure || "");
onCreated(game); onCreated(game);
} catch { } catch {
setError("Spiel konnte nicht erstellt werden."); setError("Spiel konnte nicht erstellt werden.");
@@ -84,6 +92,33 @@ export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) =
} }
} }
if (step === "adventure") {
return (
<div className="mb-6 rounded-lg border border-slate-800 bg-slate-900 p-4">
<h2 className="mb-1 text-lg font-semibold text-amber-400">Eigenes Abenteuer (optional)</h2>
<p className="mb-3 text-sm text-slate-400">
Hast du schon ein komplettes, frei verfügbares Abenteuer? Füg hier den vollständigen Text ein —
der Dungeon Master hält sich dann strikt daran (Handlung, NPCs, Orte, Encounter), statt eigene
Inhalte zu erfinden. Name und Beschreibung werden danach automatisch dazu passend vorgeschlagen.
Du kannst diesen Schritt auch überspringen.
</p>
<textarea
placeholder="Vollständiger Text eines frei verfügbaren Abenteuers..."
value={adventureText}
onChange={(e) => setAdventureText(e.target.value)}
className="w-full rounded bg-slate-800 px-3 py-2 font-mono text-xs outline-none focus:ring-2 focus:ring-amber-400"
rows={8}
/>
<button
onClick={() => setStep("chat")}
className="mt-3 rounded bg-amber-500 px-4 py-2 text-sm font-semibold text-slate-950 hover:bg-amber-400"
>
{adventureText.trim() ? "Weiter" : "Ohne Abenteuertext fortfahren"}
</button>
</div>
);
}
return ( return (
<div className="mb-6 rounded-lg border border-slate-800 bg-slate-900"> <div className="mb-6 rounded-lg border border-slate-800 bg-slate-900">
<div className="flex h-64 flex-col gap-2 overflow-y-auto p-4"> <div className="flex h-64 flex-col gap-2 overflow-y-auto p-4">
@@ -99,7 +134,13 @@ export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) =
<MarkdownContent content={turn.content} /> <MarkdownContent content={turn.content} />
</div> </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 ref={bottomRef} />
</div> </div>
<SendMessageForm onSend={handleSend} disabled={loading} placeholder="Deine Antwort..." /> <SendMessageForm onSend={handleSend} disabled={loading} placeholder="Deine Antwort..." />
@@ -120,13 +161,25 @@ 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" className="rounded bg-slate-800 px-3 py-2 outline-none focus:ring-2 focus:ring-amber-400"
rows={3} 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>} {error && <p className="text-sm text-red-400">{error}</p>}
<button <button
type="submit" type="submit"
disabled={creating} disabled={creating}
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" 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 ? "Erstelle…" : "Erstellen"} {creating
? adventureText.trim() || generatedAdventure
? "Erstelle… (Abenteuertext wird verarbeitet, kann etwas dauern)"
: "Erstelle…"
: "Erstellen"}
</button> </button>
</form> </form>
)} )}
+14 -2
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Message } from "../api/games"; import { Message } from "../api/games";
import { AwaitingRoll } from "../hooks/useGameSocket";
import MarkdownContent from "./MarkdownContent"; import MarkdownContent from "./MarkdownContent";
const DICE_FACES = ["⚀", "⚁", "⚂", "⚃", "⚄", "⚅"]; const DICE_FACES = ["⚀", "⚁", "⚂", "⚃", "⚄", "⚅"];
@@ -37,16 +38,18 @@ export default function TranscriptPane({
messages, messages,
typing, typing,
rolling, rolling,
awaitingRoll,
}: { }: {
messages: Message[]; messages: Message[];
typing?: boolean; typing?: boolean;
rolling?: string | null; rolling?: string | null;
awaitingRoll?: AwaitingRoll | null;
}) { }) {
const bottomRef = useRef<HTMLDivElement>(null); const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
bottomRef.current?.scrollIntoView({ block: "end" }); bottomRef.current?.scrollIntoView({ block: "end" });
}, [messages, typing, rolling]); }, [messages, typing, rolling, awaitingRoll]);
return ( return (
<div className="flex h-full flex-col gap-3 overflow-y-auto p-4"> <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} /> <MarkdownContent content={m.content} />
</div> </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} /> <DiceRollAnimation notation={rolling} />
) : ( ) : (
typing && ( typing && (
+10
View File
@@ -3,6 +3,11 @@ import { useEffect, useRef } from "react";
import { apiFetch } from "../api/client"; import { apiFetch } from "../api/client";
import { Message } from "../api/games"; import { Message } from "../api/games";
export interface AwaitingRoll {
notation: string;
reason: string;
}
export function useGameSocket( export function useGameSocket(
gameId: string | undefined, gameId: string | undefined,
onMessage: (message: Message) => void, onMessage: (message: Message) => void,
@@ -10,6 +15,7 @@ export function useGameSocket(
onTyping: () => void, onTyping: () => void,
onRolling: (notation: string) => void, onRolling: (notation: string) => void,
onGameEnded: (reason: string) => void, onGameEnded: (reason: string) => void,
onAwaitingRoll: (roll: AwaitingRoll) => void,
) { ) {
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
const onMessageRef = useRef(onMessage); const onMessageRef = useRef(onMessage);
@@ -22,6 +28,8 @@ export function useGameSocket(
onRollingRef.current = onRolling; onRollingRef.current = onRolling;
const onGameEndedRef = useRef(onGameEnded); const onGameEndedRef = useRef(onGameEnded);
onGameEndedRef.current = onGameEnded; onGameEndedRef.current = onGameEnded;
const onAwaitingRollRef = useRef(onAwaitingRoll);
onAwaitingRollRef.current = onAwaitingRoll;
useEffect(() => { useEffect(() => {
if (!gameId) return; if (!gameId) return;
@@ -43,6 +51,8 @@ export function useGameSocket(
else if (data.type === "typing") onTypingRef.current(); else if (data.type === "typing") onTypingRef.current();
else if (data.type === "rolling") onRollingRef.current(data.notation ?? ""); else if (data.type === "rolling") onRollingRef.current(data.notation ?? "");
else if (data.type === "game_ended") onGameEndedRef.current(data.reason ?? ""); 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 = () => { ws.onclose = () => {
if (!intentionalClose) onErrorRef.current("connection_lost"); if (!intentionalClose) onErrorRef.current("connection_lost");
+14 -2
View File
@@ -1,11 +1,12 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Character, listCharacters } from "../api/characters"; import { Character, deleteCharacter, listCharacters } from "../api/characters";
import CharacterCard from "../components/CharacterCard"; import CharacterCard from "../components/CharacterCard";
export default function CharactersList() { export default function CharactersList() {
const [characters, setCharacters] = useState<Character[]>([]); const [characters, setCharacters] = useState<Character[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
listCharacters() listCharacters()
@@ -13,9 +14,20 @@ export default function CharactersList() {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
async function handleDelete(id: string) {
setError(null);
try {
await deleteCharacter(id);
setCharacters((prev) => prev.filter((c) => c.id !== id));
} catch {
setError("Charakter konnte nicht gelöscht werden.");
}
}
return ( return (
<div className="mx-auto max-w-6xl px-4 py-8"> <div className="mx-auto max-w-6xl px-4 py-8">
<h1 className="mb-6 text-2xl font-bold text-amber-400">Charaktere</h1> <h1 className="mb-6 text-2xl font-bold text-amber-400">Charaktere</h1>
{error && <p className="mb-4 text-sm text-red-400">{error}</p>}
{loading ? ( {loading ? (
<p className="text-slate-400">Lädt…</p> <p className="text-slate-400">Lädt…</p>
) : characters.length === 0 ? ( ) : characters.length === 0 ? (
@@ -25,7 +37,7 @@ export default function CharactersList() {
) : ( ) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{characters.map((c) => ( {characters.map((c) => (
<CharacterCard key={c.id} character={c} /> <CharacterCard key={c.id} character={c} onDelete={handleDelete} />
))} ))}
</div> </div>
)} )}
+51 -7
View File
@@ -1,14 +1,23 @@
import { FormEvent, useEffect, useState } from "react"; import { FormEvent, useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom"; import { Link, useParams, useSearchParams } from "react-router-dom";
import { listCharacters, Character } from "../api/characters"; 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 SendMessageForm from "../components/SendMessageForm";
import TranscriptPane from "../components/TranscriptPane"; import TranscriptPane from "../components/TranscriptPane";
import { useGameSocket } from "../hooks/useGameSocket"; import { AwaitingRoll, useGameSocket } from "../hooks/useGameSocket";
export default function GameSession() { export default function GameSession() {
const { gameId } = useParams<{ gameId: string }>(); const { gameId } = useParams<{ gameId: string }>();
const [searchParams] = useSearchParams();
const [game, setGame] = useState<Game | null>(null); const [game, setGame] = useState<Game | null>(null);
const [messages, setMessages] = useState<Message[]>([]); const [messages, setMessages] = useState<Message[]>([]);
@@ -16,18 +25,21 @@ export default function GameSession() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [characters, setCharacters] = useState<Character[]>([]); const [characters, setCharacters] = useState<Character[]>([]);
const [joinCode, setJoinCode] = useState(""); const [joinCode, setJoinCode] = useState(() => (searchParams.get("code") ?? "").toUpperCase());
const [selectedCharacterId, setSelectedCharacterId] = useState(""); const [selectedCharacterId, setSelectedCharacterId] = useState("");
const [joinError, setJoinError] = useState<string | null>(null); const [joinError, setJoinError] = useState<string | null>(null);
const [isTyping, setIsTyping] = useState(false); const [isTyping, setIsTyping] = useState(false);
const [rollingNotation, setRollingNotation] = useState<string | null>(null); const [rollingNotation, setRollingNotation] = useState<string | null>(null);
const [endedReason, setEndedReason] = 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() { async function loadGame() {
if (!gameId) return; if (!gameId) return;
const g = await getGame(gameId); const g = await getGame(gameId);
setGame(g); setGame(g);
setEndedReason(g.status === "ended" ? g.ended_reason : null); setEndedReason(g.status === "ended" ? g.ended_reason : null);
setAwaitingRoll(g.my_pending_roll ?? null);
return g; return g;
} }
@@ -64,6 +76,7 @@ export default function GameSession() {
try { try {
const g = await joinGame(gameId, joinCode, selectedCharacterId || undefined); const g = await joinGame(gameId, joinCode, selectedCharacterId || undefined);
setGame(g); setGame(g);
setAwaitingRoll(g.my_pending_roll ?? null);
await loadRecent(); await loadRecent();
} catch { } catch {
setJoinError("Beitritt fehlgeschlagen — Teilnahmecode prüfen."); setJoinError("Beitritt fehlgeschlagen — Teilnahmecode prüfen.");
@@ -105,9 +118,23 @@ export default function GameSession() {
() => setIsTyping(true), () => setIsTyping(true),
(notation) => setRollingNotation(notation), (notation) => setRollingNotation(notation),
(reason) => setEndedReason(reason), (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) { async function handleSend(content: string) {
setAwaitingRoll(null);
send(content); send(content);
} }
@@ -162,10 +189,22 @@ export default function GameSession() {
<div className="mx-auto flex h-[calc(100vh-56px)] max-w-6xl flex-col px-4 py-4"> <div className="mx-auto flex h-[calc(100vh-56px)] max-w-6xl flex-col px-4 py-4">
<div className="mb-3 flex items-center justify-between"> <div className="mb-3 flex items-center justify-between">
<div> <div>
<h1 className="text-xl font-bold text-amber-400">{game.name}</h1> <h1 className="text-xl font-bold text-amber-400">
{game.name} {game.has_adventure && <span className="text-sm font-normal">📖</span>}
</h1>
<p className="text-xs text-slate-500">{game.description}</p> <p className="text-xs text-slate-500">{game.description}</p>
</div> </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 && ( {game.my_character_id && (
<Link <Link
to={`/characters/${game.my_character_id}?back=${gameId}`} to={`/characters/${game.my_character_id}?back=${gameId}`}
@@ -190,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"> <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} /> <SendMessageForm onSend={handleSend} />
</div> </div>
</div> </div>
+15 -2
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { Game, listGames } from "../api/games"; import { deleteGame, Game, listGames } from "../api/games";
import GameCard from "../components/GameCard"; import GameCard from "../components/GameCard";
import GameSetupChat from "../components/GameSetupChat"; import GameSetupChat from "../components/GameSetupChat";
@@ -10,6 +10,7 @@ export default function GamesList() {
const [games, setGames] = useState<Game[]>([]); const [games, setGames] = useState<Game[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false); const [showCreate, setShowCreate] = useState(false);
const [error, setError] = useState<string | null>(null);
async function load() { async function load() {
setLoading(true); setLoading(true);
@@ -24,6 +25,16 @@ export default function GamesList() {
load(); load();
}, []); }, []);
async function handleDelete(id: string) {
setError(null);
try {
await deleteGame(id);
setGames((prev) => prev.filter((g) => g.id !== id));
} catch {
setError("Spiel konnte nicht gelöscht werden.");
}
}
return ( return (
<div className="mx-auto max-w-6xl px-4 py-8"> <div className="mx-auto max-w-6xl px-4 py-8">
<div className="mb-6 flex items-center justify-between"> <div className="mb-6 flex items-center justify-between">
@@ -40,6 +51,8 @@ export default function GamesList() {
{showCreate && <GameSetupChat onCreated={(game) => navigate(`/games/${game.id}`)} />} {showCreate && <GameSetupChat onCreated={(game) => navigate(`/games/${game.id}`)} />}
{error && <p className="mb-4 text-sm text-red-400">{error}</p>}
{loading ? ( {loading ? (
<p className="text-slate-400">Lädt…</p> <p className="text-slate-400">Lädt…</p>
) : games.length === 0 ? ( ) : games.length === 0 ? (
@@ -47,7 +60,7 @@ export default function GamesList() {
) : ( ) : (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{games.map((game) => ( {games.map((game) => (
<GameCard key={game.id} game={game} /> <GameCard key={game.id} game={game} onDelete={handleDelete} />
))} ))}
</div> </div>
)} )}
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Dumps the Postgres database from the running "postgres" container into ./backups/,
# in pg_dump's custom format (compressed, restorable with pg_restore — see restore-db.sh).
#
# Usage: scripts/backup-db.sh
set -euo pipefail
cd "$(dirname "$0")/.."
mkdir -p backups
timestamp="$(date +%Y%m%d_%H%M%S)"
out="backups/dnd_backup_${timestamp}.dump"
trap 'rm -f "${out}"' ERR
echo "Dumping database into ${out} ..."
docker compose exec -T postgres sh -c 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -F c' > "${out}"
size="$(du -h "${out}" | cut -f1)"
echo "Done: ${out} (${size})"
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Restores a dump produced by backup-db.sh into the running "postgres" container.
# Destructive: drops and recreates every object in the target database before restoring.
#
# Usage: scripts/restore-db.sh path/to/dnd_backup_20260101_120000.dump [-y]
# -y skip the confirmation prompt (for non-interactive/scripted use)
set -euo pipefail
cd "$(dirname "$0")/.."
skip_confirm=false
file=""
for arg in "$@"; do
if [ "$arg" = "-y" ]; then
skip_confirm=true
else
file="$arg"
fi
done
if [ -z "$file" ]; then
echo "Usage: $0 path/to/backup.dump [-y]" >&2
exit 1
fi
if [ ! -f "$file" ]; then
echo "Error: file not found: $file" >&2
exit 1
fi
if [ "$skip_confirm" = false ]; then
read -r -p "This will DROP and recreate every table in the target database. Continue? [y/N] " reply
case "$reply" in
[yY]|[yY][eE][sS]) ;;
*) echo "Aborted."; exit 1 ;;
esac
fi
echo "Restoring ${file} ..."
docker compose exec -T postgres sh -c \
'pg_restore -U "$POSTGRES_USER" -d "$POSTGRES_DB" --clean --if-exists --no-owner --no-privileges --single-transaction' \
< "$file"
echo "Done."