Compare commits

..

10 Commits

Author SHA1 Message Date
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
38 changed files with 1055 additions and 109 deletions
+2
View File
@@ -0,0 +1,2 @@
* text=auto
*.sh text eol=lf
+1
View File
@@ -12,6 +12,7 @@ build/
caddy_data/
caddy_config/
pgdata/
backups/
frontend/dist/
*.tsbuildinfo
.DS_Store
@@ -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
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.users import current_active_user
from app.db import get_async_session
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.schemas.character import CharacterRead
@@ -40,3 +42,30 @@ async def get_character(
if character.owner_id != user.id:
raise HTTPException(status_code=403, detail="Not your 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()
+24 -1
View File
@@ -13,6 +13,7 @@ from app.models.character import Character
from app.models.game import Game, GameParticipant
from app.models.message import Message
from app.models.user import User
from app.rag.adventure_ingestion import ingest_adventure_text
from app.schemas.game import GameCreate, GameJoin, GameRead
from app.schemas.game_setup import GameSetupChatRequest, GameSetupChatResponse
from app.schemas.message import MessageRead
@@ -73,6 +74,7 @@ async def _serialize_games(
my_character_id=viewer_participation.get(game.id),
status=game.status,
ended_reason=game.ended_reason,
has_adventure=bool(game.adventure_text.strip()),
)
)
return result
@@ -98,6 +100,7 @@ async def create_game(
game = Game(
name=payload.name,
description=payload.description,
adventure_text=payload.adventure_text,
creator_id=user.id,
participation_code=_generate_participation_code(),
)
@@ -108,6 +111,9 @@ async def create_game(
await session.commit()
await session.refresh(game)
if payload.adventure_text.strip():
await ingest_adventure_text(session, game.id, payload.adventure_text)
serialized = await _serialize_games(session, [game], user.id)
return serialized[0]
@@ -117,7 +123,7 @@ async def game_setup_chat(
payload: GameSetupChatRequest,
user: User = Depends(current_active_user),
) -> GameSetupChatResponse:
result = await run_game_setup_turn(payload.messages)
result = await run_game_setup_turn(payload.messages, payload.adventure_text)
return GameSetupChatResponse(**result)
@@ -134,6 +140,23 @@ async def get_game(
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]:
if not messages:
return []
+46 -24
View File
@@ -4,6 +4,7 @@ import uuid
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import async_session_maker
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.ws_tickets import consume_ticket
KICKOFF_QUERY = "Beginne die Sitzung und begrüße die anwesenden Spieler."
logger = logging.getLogger("app.ws_game")
router = APIRouter()
@@ -70,6 +73,36 @@ 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
) -> 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_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,
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"})
return
await manager.broadcast(
game_id,
{"type": "message", "message": await _serialize_message(session, dm_message)},
)
@router.websocket("/ws/games/{game_id}")
async def game_websocket(websocket: WebSocket, game_id: uuid.UUID, ticket: str) -> None:
user_id = consume_ticket(ticket, game_id)
@@ -80,6 +113,18 @@ async def game_websocket(websocket: WebSocket, game_id: uuid.UUID, ticket: str)
await websocket.accept()
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)
try:
while True:
data = await websocket.receive_json()
@@ -118,31 +163,8 @@ async def game_websocket(websocket: WebSocket, game_id: uuid.UUID, ticket: str)
game_id,
{"type": "message", "message": await _serialize_message(session, player_message)},
)
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_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)},
)
await _run_and_broadcast_dm_turn(session, game_id, content)
except WebSocketDisconnect:
pass
finally:
+41 -9
View File
@@ -1,22 +1,32 @@
import uuid
from datetime import datetime
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.character import Character
from app.models.message import Message
from app.models.user import User
# Rough char-based budget for Phase 1 (~4 chars/token, leaves headroom for system prompt + output).
MAX_CONTEXT_CHARS = 40_000
# Rough char-based budget for the raw message window (~4 chars/token). Kept fairly small now that
# 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]:
messages = (
await session.execute(
select(Message).where(Message.game_id == game_id).order_by(Message.id.asc())
)
).scalars().all()
async def _load_messages(
session: AsyncSession, game_id: uuid.UUID, since: datetime | None = None
) -> list[Message]:
query = select(Message).where(Message.game_id == game_id)
if since is not None:
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:
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
entries.append({"role": "user", "content": f"{label}: {m.content}"})
# 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.
total = 0
@@ -57,3 +72,20 @@ async def build_context(session: AsyncSession, game_id: uuid.UUID) -> list[dict]
kept.reverse()
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)
+13 -2
View File
@@ -7,18 +7,29 @@ from app.llm.tools import game_setup as game_setup_tool
MAX_ROUNDS = 3
MAX_TOKENS = 1024
# Only the opening of a pasted adventure is needed here to infer genre/world/scope for the
# proposal — this is a cheap interview call, not the RAG pipeline that handles the full text.
ADVENTURE_EXCERPT_CHARS = 3000
TOOLS = [{"type": "function", "function": game_setup_tool.TOOL_SCHEMA}]
async def run_game_setup_turn(client_messages: list[dict]) -> dict:
async def run_game_setup_turn(client_messages: list[dict], adventure_text: str = "") -> dict:
"""Stateless setup-wizard turn: the caller owns conversation history (no DB, no game yet).
Returns {"messages": <history to resend next turn>, "assistant_text": str | None,
"proposal": {"name": str, "description": str} | None}.
"""
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
proposal: dict | None = None
+38 -2
View File
@@ -3,15 +3,18 @@ import logging
import uuid
from collections.abc import Awaitable, Callable
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.llm.client import get_dm_system_prompt, get_llm_client
from app.llm.context import build_context
from app.llm.json_utils import fix_double_escaped_unicode
from app.llm.tools import character_sheet, dice, end_game
from app.models.game import Game
from app.llm.tools import character_sheet, dice, end_game, monster, world_state
from app.models.game import Game, GameParticipant
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
logger = logging.getLogger("app.llm.orchestrator")
@@ -23,6 +26,8 @@ TOOLS = [
{"type": "function", "function": dice.TOOL_SCHEMA},
{"type": "function", "function": character_sheet.TOOL_SCHEMA},
{"type": "function", "function": end_game.TOOL_SCHEMA},
{"type": "function", "function": monster.TOOL_SCHEMA},
{"type": "function", "function": world_state.TOOL_SCHEMA},
]
@@ -38,6 +43,10 @@ async def _execute_tool_call(
return await character_sheet.upsert(session, game_id, tool_input)
if tool_name == "end_game":
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)
return {"error": f"Unknown tool {tool_name!r}"}
except Exception as exc: # noqa: BLE001
logger.warning("Tool call %s failed: %s", tool_name, exc)
@@ -82,11 +91,38 @@ async def run_dm_turn(
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."
)
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:
rag_block = await build_rag_block(session, latest_player_message)
if 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.extend(await build_context(session, game_id))
+10 -9
View File
@@ -1,15 +1,14 @@
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.
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.
2. Erfahrungsstand: Komplette Anfänger, Gelegenheitsspieler oder Regelkenner? Frage das pro Spieler ab, falls die Gruppe gemischt ist.
3. Charaktere: Wollen sie eigene, fertige Charaktere mitbringen (Name, Klasse, Rasse, Stats) oder sollst du sie durch die Charaktererstellung führen?
4. Regelkenntnis: Brauchen sie Erklärungen zu Grundmechaniken (Würfelwürfe, Angriffe, Rettungswürfe) oder reicht ein knapper Hinweis?
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, ...)?
6. Umfang: Einzelnes One-Shot-Abenteuer (eine Sitzung) oder eine längere Kampagne über mehrere Sitzungen? Grobe Zeit, die sie investieren wollen?
7. Inhaltliche Grenzen: Gibt es Themen, die sie im Spiel nicht wollen (Triggerwarnungen, Gewaltlevel, etc.)?
1. Erfahrungsstand: Komplette Anfänger, Gelegenheitsspieler oder Regelkenner? Frage das pro Spieler ab, falls die Gruppe gemischt ist.
2. Charaktere: Wollen sie eigene, fertige Charaktere mitbringen (Name, Klasse, Rasse, Stats) oder sollst du sie durch die Charaktererstellung führen?
3. Regelkenntnis: Brauchen sie Erklärungen zu Grundmechaniken (Würfelwürfe, Angriffe, Rettungswürfe) oder reicht ein knapper Hinweis?
4. Setting/Welt: Vorgefertigte Welt (z. B. Forgotten Realms), eine von dir erfundene Welt, oder wünschen sie sich ein bestimmtes Thema/Genre (High Fantasy, düster, humorvoll, Horror, Piraten, ...)?
5. Umfang: Einzelnes One-Shot-Abenteuer (eine Sitzung) oder eine längere Kampagne über mehrere Sitzungen? Grobe Zeit, die sie investieren wollen?
6. 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.
Phase 2: Das Spiel leiten
@@ -22,8 +21,9 @@ Phase 2: Das Spiel leiten
- 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.
* 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.
* 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.
* 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 +32,7 @@ Regeln & Wissensquellen
* 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.
* 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
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?
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
- einer kurzen Beschreibung (Genre und Welt, optional ein Hinweis auf Umfang und Erfahrungsstand)
+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.game import Game, GameParticipant
from app.models.message import Message
from app.models.rulebook_chunk import RulebookChunk
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)
+7 -1
View File
@@ -2,7 +2,7 @@ import uuid
from datetime import datetime
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 app.db import Base
@@ -24,6 +24,12 @@ class Game(Base):
)
status: Mapped[str] = mapped_column(String(length=20), nullable=False, default="active")
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(
server_default=func.now(), nullable=False, index=True
)
+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)
+2
View File
@@ -7,6 +7,7 @@ from pydantic import BaseModel
class GameCreate(BaseModel):
name: str
description: str = ""
adventure_text: str = ""
class GameRead(BaseModel):
@@ -23,6 +24,7 @@ class GameRead(BaseModel):
my_character_id: uuid.UUID | None
status: str
ended_reason: str | None
has_adventure: bool
class GameJoin(BaseModel):
+1
View File
@@ -3,6 +3,7 @@ from pydantic import BaseModel
class GameSetupChatRequest(BaseModel):
messages: list[dict] = []
adventure_text: str = ""
class GameSetupProposal(BaseModel):
+8
View File
@@ -27,5 +27,13 @@ services:
ports:
- "8000:8000"
frontend:
build: ./frontend
restart: unless-stopped
depends_on:
- backend
ports:
- "8080:80"
volumes:
pgdata:
+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
WORKDIR /app
COPY package.json .
RUN npm install
COPY package.json package-lock.json .
RUN npm ci
COPY . .
RUN npm run build
FROM scratch AS dist
COPY --from=build /app/dist /
FROM nginx:1.27-alpine
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 getCharacter = (id: string) => apiFetch<Character>(`/api/characters/${id}`);
export const deleteCharacter = (id: string) =>
apiFetch<void>(`/api/characters/${id}`, { method: "DELETE" });
+14 -4
View File
@@ -14,6 +14,7 @@ export interface Game {
my_character_id: string | null;
status: "active" | "ended";
ended_reason: string | null;
has_adventure: boolean;
}
export interface Message {
@@ -40,18 +41,27 @@ export interface GameSetupChatResponse {
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", {
method: "POST",
body: JSON.stringify({ messages }),
body: JSON.stringify({ messages, adventure_text: adventureText ?? "" }),
signal,
});
export const createGame = (name: string, description: string) =>
apiFetch<Game>("/api/games", { method: "POST", body: JSON.stringify({ name, description }) });
export const createGame = (name: string, description: string, adventureText?: string) =>
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 deleteGame = (id: string) => apiFetch<void>(`/api/games/${id}`, { method: "DELETE" });
export const joinGame = (id: string, participation_code: string, character_id?: string) =>
apiFetch<Game>(`/api/games/${id}/join`, {
method: "POST",
+53 -10
View File
@@ -1,17 +1,60 @@
import { useState } from "react";
import { Link } from "react-router-dom";
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 (
<Link
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">
{character.race ?? "?"} · {character.char_class ?? "?"} · Stufe {character.level}
</p>
</Link>
<>
<Link
to={`/characters/${character.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">{character.name}</h2>
{isOwner && onDelete && (
<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 { 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 [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 (
<Link
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>
{game.status === "ended" && (
<span className="shrink-0 rounded bg-slate-700 px-2 py-1 text-xs font-semibold text-slate-300">
Beendet
<>
<Link
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>
{game.status === "ended" && (
<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>
)}
{game.participation_code && (
<span className="shrink-0 rounded bg-amber-500/20 px-2 py-1 font-mono text-xs text-amber-300">
Code: {game.participation_code}
</span>
)}
</div>
<p className="mt-1 text-sm text-slate-400">{game.description}</p>
<div className="mt-3 flex flex-wrap 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>
</div>
</Link>
{game.has_adventure && <span>📖 Eigenes Abenteuer</span>}
{isOwner && onDelete && (
<button
onClick={handleDeleteClick}
className="ml-auto rounded px-2 py-1 text-red-400 hover:bg-red-500/10 hover:text-red-300"
>
Löschen
</button>
)}
</div>
</Link>
<ConfirmDialog
open={confirming}
title="Spiel löschen?"
message={`„${game.name}" wird endgültig gelöscht, inklusive des gesamten Chatverlaufs. Das kann nicht rückgängig gemacht werden.`}
confirmLabel="Endgültig löschen"
onCancel={() => setConfirming(false)}
onConfirm={() => {
setConfirming(false);
onDelete?.(game.id);
}}
/>
</>
);
}
+40 -6
View File
@@ -10,12 +10,14 @@ interface DisplayTurn {
}
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 [turns, setTurns] = useState<DisplayTurn[]>([]);
const [proposal, setProposal] = useState<GameSetupProposal | null>(null);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [loading, setLoading] = useState(true);
const [loading, setLoading] = useState(false);
const [creating, setCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
const bottomRef = useRef<HTMLDivElement>(null);
@@ -43,11 +45,12 @@ export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) =
}
useEffect(() => {
if (step !== "chat") return;
const controller = new AbortController();
(async () => {
setLoading(true);
try {
const res = await runGameSetupChat([], controller.signal);
const res = await runGameSetupChat([], adventureText, controller.signal);
applyResponse(res);
} catch (err) {
if (!(err instanceof DOMException && err.name === "AbortError")) throw err;
@@ -59,7 +62,7 @@ export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) =
controller.abort();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [step]);
useEffect(() => {
bottomRef.current?.scrollIntoView({ block: "end" });
@@ -68,7 +71,7 @@ export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) =
async function handleSend(content: string) {
setTurns((prev) => [...prev, { role: "user", content }]);
const nextMessages = [...rawMessages, { role: "user", content }];
applyResponse(await runGameSetupChat(nextMessages));
applyResponse(await runGameSetupChat(nextMessages, adventureText));
}
async function handleCreate(e: FormEvent) {
@@ -76,7 +79,7 @@ export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) =
setCreating(true);
setError(null);
try {
const game = await createGame(name, description);
const game = await createGame(name, description, adventureText);
onCreated(game);
} catch {
setError("Spiel konnte nicht erstellt werden.");
@@ -84,6 +87,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 (
<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">
@@ -126,7 +156,11 @@ export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) =
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"
>
{creating ? "Erstelle…" : "Erstellen"}
{creating
? adventureText.trim()
? "Erstelle… (Abenteuertext wird verarbeitet, kann etwas dauern)"
: "Erstelle…"
: "Erstellen"}
</button>
</form>
)}
+14 -2
View File
@@ -1,11 +1,12 @@
import { useEffect, useState } from "react";
import { Character, listCharacters } from "../api/characters";
import { Character, deleteCharacter, listCharacters } from "../api/characters";
import CharacterCard from "../components/CharacterCard";
export default function CharactersList() {
const [characters, setCharacters] = useState<Character[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
listCharacters()
@@ -13,9 +14,20 @@ export default function CharactersList() {
.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 (
<div className="mx-auto max-w-6xl px-4 py-8">
<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 ? (
<p className="text-slate-400">Lädt…</p>
) : characters.length === 0 ? (
@@ -25,7 +37,7 @@ export default function CharactersList() {
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{characters.map((c) => (
<CharacterCard key={c.id} character={c} />
<CharacterCard key={c.id} character={c} onDelete={handleDelete} />
))}
</div>
)}
+6 -3
View File
@@ -1,5 +1,5 @@
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 { Game, getFullMessages, getGame, getRecentMessages, joinGame, Message } from "../api/games";
@@ -9,6 +9,7 @@ import { useGameSocket } from "../hooks/useGameSocket";
export default function GameSession() {
const { gameId } = useParams<{ gameId: string }>();
const [searchParams] = useSearchParams();
const [game, setGame] = useState<Game | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
@@ -16,7 +17,7 @@ export default function GameSession() {
const [loading, setLoading] = useState(true);
const [characters, setCharacters] = useState<Character[]>([]);
const [joinCode, setJoinCode] = useState("");
const [joinCode, setJoinCode] = useState(() => (searchParams.get("code") ?? "").toUpperCase());
const [selectedCharacterId, setSelectedCharacterId] = useState("");
const [joinError, setJoinError] = useState<string | null>(null);
const [isTyping, setIsTyping] = useState(false);
@@ -162,7 +163,9 @@ export default function GameSession() {
<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>
<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>
</div>
<div className="flex gap-2">
+15 -2
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
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 GameSetupChat from "../components/GameSetupChat";
@@ -10,6 +10,7 @@ export default function GamesList() {
const [games, setGames] = useState<Game[]>([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [error, setError] = useState<string | null>(null);
async function load() {
setLoading(true);
@@ -24,6 +25,16 @@ export default function GamesList() {
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 (
<div className="mx-auto max-w-6xl px-4 py-8">
<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}`)} />}
{error && <p className="mb-4 text-sm text-red-400">{error}</p>}
{loading ? (
<p className="text-slate-400">Lädt…</p>
) : games.length === 0 ? (
@@ -47,7 +60,7 @@ export default function GamesList() {
) : (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{games.map((game) => (
<GameCard key={game.id} game={game} />
<GameCard key={game.id} game={game} onDelete={handleDelete} />
))}
</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."