Compare commits
16 Commits
8da834c5c1
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c374f2bc2 | |||
| f9b9785857 | |||
| 1b64010280 | |||
| d62970ee1b | |||
| cc680dc392 | |||
| f5c62d27fd | |||
| fb19795fdb | |||
| a58730fce7 | |||
| b75cfb1437 | |||
| 7bbdee868a | |||
| 07665595a2 | |||
| b77f066259 | |||
| ff4c6f07e3 | |||
| a8763e175f | |||
| 81eaf667cd | |||
| 2b2af058b8 |
+1
-1
@@ -5,7 +5,7 @@ LLM_PROVIDER=xai
|
|||||||
|
|
||||||
# x.ai API (Grok) — used when LLM_PROVIDER=xai. Get a key at https://console.x.ai
|
# 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
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
* text=auto
|
||||||
|
*.sh text eol=lf
|
||||||
@@ -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')
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,6 +241,14 @@ 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 participant is not None and participant.self_rolls:
|
||||||
|
notation = tool_input.get("notation", "")
|
||||||
|
reason = tool_input.get("reason", "")
|
||||||
|
result = await _request_player_roll(session, participant, notation, reason)
|
||||||
|
if "error" not in result and on_awaiting_roll is not None:
|
||||||
|
await on_awaiting_roll(notation, reason)
|
||||||
|
else:
|
||||||
if tool_call.function.name == "roll_dice" and on_roll is not None:
|
if tool_call.function.name == "roll_dice" and on_roll is not None:
|
||||||
await on_roll(tool_input.get("notation", ""))
|
await on_roll(tool_input.get("notation", ""))
|
||||||
result = await _execute_tool_call(session, game_id, tool_call.function.name, tool_input)
|
result = await _execute_tool_call(session, game_id, tool_call.function.name, tool_input)
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ TOOL_SCHEMA = {
|
|||||||
"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,
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
@@ -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
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
+6
-4
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" });
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
<Link
|
||||||
to={`/characters/${character.id}`}
|
to={`/characters/${character.id}`}
|
||||||
className="block rounded-lg border border-slate-800 bg-slate-900 p-4 hover:border-amber-500"
|
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>
|
<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">
|
<p className="text-sm text-slate-400">
|
||||||
{character.race ?? "?"} · {character.char_class ?? "?"} · Stufe {character.level}
|
{character.race ?? "?"} · {character.char_class ?? "?"} · Stufe {character.level}
|
||||||
</p>
|
</p>
|
||||||
</Link>
|
</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);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,40 @@
|
|||||||
|
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
|
<Link
|
||||||
to={`/games/${game.id}`}
|
to={`/games/${game.id}`}
|
||||||
className="block rounded-lg border border-slate-800 bg-slate-900 p-4 hover:border-amber-500"
|
className="block rounded-lg border border-slate-800 bg-slate-900 p-4 hover:border-amber-500"
|
||||||
@@ -18,20 +47,58 @@ export default function GameCard({ game }: { game: Game }) {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{game.participation_code && (
|
{game.participation_code && (
|
||||||
<span className="shrink-0 rounded bg-amber-500/20 px-2 py-1 font-mono text-xs text-amber-300">
|
<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}
|
Code: {game.participation_code}
|
||||||
</span>
|
<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>
|
</div>
|
||||||
<p className="mt-1 text-sm text-slate-400">{game.description}</p>
|
<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">
|
<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>Erstellt: {created}</span>
|
||||||
<span>Von: {game.creator_name}</span>
|
<span>Von: {game.creator_name}</span>
|
||||||
<span>
|
<span>
|
||||||
{game.player_count} Spieler
|
{game.player_count} Spieler
|
||||||
{game.player_names.length > 0 && `: ${game.player_names.join(", ")}`}
|
{game.player_names.length > 0 && `: ${game.player_names.join(", ")}`}
|
||||||
</span>
|
</span>
|
||||||
|
{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>
|
</div>
|
||||||
</Link>
|
</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);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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})"
|
||||||
@@ -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."
|
||||||
Reference in New Issue
Block a user