Compare commits
10 Commits
8da834c5c1
...
fb19795fdb
| Author | SHA1 | Date | |
|---|---|---|---|
| fb19795fdb | |||
| a58730fce7 | |||
| b75cfb1437 | |||
| 7bbdee868a | |||
| 07665595a2 | |||
| b77f066259 | |||
| ff4c6f07e3 | |||
| a8763e175f | |||
| 81eaf667cd | |||
| 2b2af058b8 |
@@ -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,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,6 +13,7 @@ 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.rag.adventure_ingestion import ingest_adventure_text
|
||||||
from app.schemas.game import GameCreate, GameJoin, GameRead
|
from app.schemas.game import GameCreate, GameJoin, GameRead
|
||||||
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
|
||||||
@@ -73,6 +74,7 @@ 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()),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
@@ -98,6 +100,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 +111,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 +123,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 +140,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 []
|
||||||
|
|||||||
+46
-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,36 @@ async def _serialize_message(session, message: Message) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_and_broadcast_dm_turn(
|
||||||
|
session: AsyncSession, game_id: uuid.UUID, latest_player_message: str
|
||||||
|
) -> None:
|
||||||
|
await manager.broadcast(game_id, {"type": "typing"})
|
||||||
|
|
||||||
|
async def _broadcast_roll(notation: str) -> None:
|
||||||
|
await manager.broadcast(game_id, {"type": "rolling", "notation": notation})
|
||||||
|
|
||||||
|
async def _broadcast_game_ended(reason: str) -> None:
|
||||||
|
await manager.broadcast(game_id, {"type": "game_ended", "reason": reason})
|
||||||
|
|
||||||
|
try:
|
||||||
|
dm_message = await run_dm_turn(
|
||||||
|
session,
|
||||||
|
game_id,
|
||||||
|
latest_player_message=latest_player_message,
|
||||||
|
on_roll=_broadcast_roll,
|
||||||
|
on_game_ended=_broadcast_game_ended,
|
||||||
|
)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.exception("DM turn failed for game %s", game_id)
|
||||||
|
await manager.broadcast(game_id, {"type": "error", "detail": "dm_turn_failed"})
|
||||||
|
return
|
||||||
|
|
||||||
|
await manager.broadcast(
|
||||||
|
game_id,
|
||||||
|
{"type": "message", "message": await _serialize_message(session, dm_message)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.websocket("/ws/games/{game_id}")
|
@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 +113,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)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
data = await websocket.receive_json()
|
data = await websocket.receive_json()
|
||||||
@@ -118,31 +163,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)
|
||||||
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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -7,18 +7,29 @@ from app.llm.tools import game_setup as game_setup_tool
|
|||||||
|
|
||||||
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
|
||||||
|
|
||||||
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 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}.
|
||||||
"""
|
"""
|
||||||
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
|
||||||
|
|||||||
@@ -3,15 +3,18 @@ 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, monster, world_state
|
||||||
from app.models.game import Game
|
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 +26,8 @@ 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},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -38,6 +43,10 @@ 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)
|
||||||
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)
|
||||||
@@ -82,11 +91,38 @@ 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."
|
||||||
|
)
|
||||||
|
|
||||||
|
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))
|
||||||
|
|
||||||
|
|||||||
@@ -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 eigene, fertige Charaktere mitbringen (Name, Klasse, Rasse, Stats) oder sollst du sie durch die Charaktererstellung führen?
|
||||||
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
|
||||||
@@ -22,8 +21,9 @@ Phase 2: Das Spiel leiten
|
|||||||
- Keine Probe nötig (z. B. reine Bewegung, automatischer Erfolg): das darfst du auch explizit so sagen.
|
- 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. Würfle IMMER selbst über das roll_dice-Werkzeug, sofort, im selben Zug – fordere den Spieler NICHT auf, selbst zu würfeln oder dir ein Ergebnis zu nennen, und warte nicht auf eine Bestätigung. Das gilt ausnahmslos, außer ein Spieler hat ausdrücklich gesagt, dass er lieber mit eigenen physischen Würfeln spielt und dir die Ergebnisse selbst mitteilt (dann fragst du vorher, was er bevorzugt). Ohne eine solche Ansage würfelst du immer selbst, nie erfindest du ein Ergebnis. Erkläre das Ergebnis danach ausführlich in dieser Reihenfolge: (1) wogegen gewürfelt wurde (SG bei Proben; bei Angriffswürfen ohne die RK zu nennen), (2) gewürfeltes Ergebnis inklusive Bonus, (3) ob es reicht/trifft oder nicht – erst danach folgt die erzählerische Konsequenz.
|
||||||
* Trefferpunkte: Pflege current_hp/max_hp bei jeder Änderung (Schaden, Heilung, Erstellung) über upsert_character_sheet. Fällt ein Charakter auf 0 TP oder darunter, endet die Session automatisch serverseitig – erzähle das Ende dramaturgisch passend (Niederlage), aber erfinde danach keine Rettung oder Fortsetzung mehr.
|
* 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 +32,7 @@ Regeln & Wissensquellen
|
|||||||
* Du bist Regelexperte: Bei Unsicherheiten der Spieler erklärst du Mechaniken (Kämpfe, Zauber, Fertigkeiten, Zustände) verständlich und korrekt.
|
* 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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,6 +24,7 @@ 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
|
||||||
|
|
||||||
|
|
||||||
class GameJoin(BaseModel):
|
class GameJoin(BaseModel):
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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,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,7 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
@@ -40,18 +41,27 @@ export interface GameSetupChatResponse {
|
|||||||
|
|
||||||
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 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
|
<>
|
||||||
to={`/characters/${character.id}`}
|
<Link
|
||||||
className="block rounded-lg border border-slate-800 bg-slate-900 p-4 hover:border-amber-500"
|
to={`/characters/${character.id}`}
|
||||||
>
|
className="block rounded-lg border border-slate-800 bg-slate-900 p-4 hover:border-amber-500"
|
||||||
<h2 className="text-lg font-semibold text-slate-100">{character.name}</h2>
|
>
|
||||||
<p className="text-sm text-slate-400">
|
<div className="flex items-start justify-between gap-4">
|
||||||
{character.race ?? "?"} · {character.char_class ?? "?"} · Stufe {character.level}
|
<h2 className="text-lg font-semibold text-slate-100">{character.name}</h2>
|
||||||
</p>
|
{isOwner && onDelete && (
|
||||||
</Link>
|
<button
|
||||||
|
onClick={handleDeleteClick}
|
||||||
|
className="shrink-0 rounded px-2 py-1 text-xs text-red-400 hover:bg-red-500/10 hover:text-red-300"
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
{character.race ?? "?"} · {character.char_class ?? "?"} · Stufe {character.level}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirming}
|
||||||
|
title="Charakter löschen?"
|
||||||
|
message={`„${character.name}" wird endgültig gelöscht. Spiele, in denen der Charakter mitgespielt hat, bleiben erhalten, verlieren aber die Verknüpfung zu ihm.`}
|
||||||
|
confirmLabel="Endgültig löschen"
|
||||||
|
onCancel={() => setConfirming(false)}
|
||||||
|
onConfirm={() => {
|
||||||
|
setConfirming(false);
|
||||||
|
onDelete?.(character.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,37 +1,104 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
import { Game } from "../api/games";
|
import { Game } from "../api/games";
|
||||||
|
import { useAuth } from "../hooks/useAuth";
|
||||||
|
import ConfirmDialog from "./ConfirmDialog";
|
||||||
|
|
||||||
export default function GameCard({ game }: { game: Game }) {
|
export default function GameCard({
|
||||||
|
game,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
game: Game;
|
||||||
|
onDelete?: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const { user } = useAuth();
|
||||||
const created = new Date(game.created_at).toLocaleString("de-DE");
|
const created = new Date(game.created_at).toLocaleString("de-DE");
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
const isOwner = user?.id === game.creator_id;
|
||||||
|
|
||||||
|
async function handleCopyLink(e: React.MouseEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const link = `${window.location.origin}/games/${game.id}?code=${game.participation_code}`;
|
||||||
|
await navigator.clipboard.writeText(link);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDeleteClick(e: React.MouseEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setConfirming(true);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<>
|
||||||
to={`/games/${game.id}`}
|
<Link
|
||||||
className="block rounded-lg border border-slate-800 bg-slate-900 p-4 hover:border-amber-500"
|
to={`/games/${game.id}`}
|
||||||
>
|
className="block rounded-lg border border-slate-800 bg-slate-900 p-4 hover:border-amber-500"
|
||||||
<div className="flex items-start justify-between gap-4">
|
>
|
||||||
<h2 className="text-lg font-semibold text-slate-100">{game.name}</h2>
|
<div className="flex items-start justify-between gap-4">
|
||||||
{game.status === "ended" && (
|
<h2 className="text-lg font-semibold text-slate-100">{game.name}</h2>
|
||||||
<span className="shrink-0 rounded bg-slate-700 px-2 py-1 text-xs font-semibold text-slate-300">
|
{game.status === "ended" && (
|
||||||
Beendet
|
<span className="shrink-0 rounded bg-slate-700 px-2 py-1 text-xs font-semibold text-slate-300">
|
||||||
|
Beendet
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{game.participation_code && (
|
||||||
|
<button
|
||||||
|
onClick={handleCopyLink}
|
||||||
|
title="Einladungslink kopieren"
|
||||||
|
className="flex shrink-0 items-center gap-1.5 rounded bg-amber-500/20 px-2 py-1 font-mono text-xs text-amber-300 hover:bg-amber-500/30"
|
||||||
|
>
|
||||||
|
Code: {game.participation_code}
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
>
|
||||||
|
<rect x="9" y="9" width="11" height="11" rx="1.5" />
|
||||||
|
<path d="M5 15V5a2 2 0 0 1 2-2h10" />
|
||||||
|
</svg>
|
||||||
|
{copied && <span className="text-emerald-400">Kopiert!</span>}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-slate-400">{game.description}</p>
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-slate-500">
|
||||||
|
<span>Erstellt: {created}</span>
|
||||||
|
<span>Von: {game.creator_name}</span>
|
||||||
|
<span>
|
||||||
|
{game.player_count} Spieler
|
||||||
|
{game.player_names.length > 0 && `: ${game.player_names.join(", ")}`}
|
||||||
</span>
|
</span>
|
||||||
)}
|
{game.has_adventure && <span>📖 Eigenes Abenteuer</span>}
|
||||||
{game.participation_code && (
|
{isOwner && onDelete && (
|
||||||
<span className="shrink-0 rounded bg-amber-500/20 px-2 py-1 font-mono text-xs text-amber-300">
|
<button
|
||||||
Code: {game.participation_code}
|
onClick={handleDeleteClick}
|
||||||
</span>
|
className="ml-auto rounded px-2 py-1 text-red-400 hover:bg-red-500/10 hover:text-red-300"
|
||||||
)}
|
>
|
||||||
</div>
|
Löschen
|
||||||
<p className="mt-1 text-sm text-slate-400">{game.description}</p>
|
</button>
|
||||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-500">
|
)}
|
||||||
<span>Erstellt: {created}</span>
|
</div>
|
||||||
<span>Von: {game.creator_name}</span>
|
</Link>
|
||||||
<span>
|
|
||||||
{game.player_count} Spieler
|
<ConfirmDialog
|
||||||
{game.player_names.length > 0 && `: ${game.player_names.join(", ")}`}
|
open={confirming}
|
||||||
</span>
|
title="Spiel löschen?"
|
||||||
</div>
|
message={`„${game.name}" wird endgültig gelöscht, inklusive des gesamten Chatverlaufs. Das kann nicht rückgängig gemacht werden.`}
|
||||||
</Link>
|
confirmLabel="Endgültig löschen"
|
||||||
|
onCancel={() => setConfirming(false)}
|
||||||
|
onConfirm={() => {
|
||||||
|
setConfirming(false);
|
||||||
|
onDelete?.(game.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,12 +10,14 @@ 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 [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);
|
||||||
@@ -43,11 +45,12 @@ export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) =
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 +62,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 +71,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 +79,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);
|
||||||
onCreated(game);
|
onCreated(game);
|
||||||
} catch {
|
} catch {
|
||||||
setError("Spiel konnte nicht erstellt werden.");
|
setError("Spiel konnte nicht erstellt werden.");
|
||||||
@@ -84,6 +87,33 @@ export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (step === "adventure") {
|
||||||
|
return (
|
||||||
|
<div className="mb-6 rounded-lg border border-slate-800 bg-slate-900 p-4">
|
||||||
|
<h2 className="mb-1 text-lg font-semibold text-amber-400">Eigenes Abenteuer (optional)</h2>
|
||||||
|
<p className="mb-3 text-sm text-slate-400">
|
||||||
|
Hast du schon ein komplettes, frei verfügbares Abenteuer? Füg hier den vollständigen Text ein —
|
||||||
|
der Dungeon Master hält sich dann strikt daran (Handlung, NPCs, Orte, Encounter), statt eigene
|
||||||
|
Inhalte zu erfinden. Name und Beschreibung werden danach automatisch dazu passend vorgeschlagen.
|
||||||
|
Du kannst diesen Schritt auch überspringen.
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
placeholder="Vollständiger Text eines frei verfügbaren Abenteuers..."
|
||||||
|
value={adventureText}
|
||||||
|
onChange={(e) => setAdventureText(e.target.value)}
|
||||||
|
className="w-full rounded bg-slate-800 px-3 py-2 font-mono text-xs outline-none focus:ring-2 focus:ring-amber-400"
|
||||||
|
rows={8}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => setStep("chat")}
|
||||||
|
className="mt-3 rounded bg-amber-500 px-4 py-2 text-sm font-semibold text-slate-950 hover:bg-amber-400"
|
||||||
|
>
|
||||||
|
{adventureText.trim() ? "Weiter" : "Ohne Abenteuertext fortfahren"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
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">
|
||||||
@@ -126,7 +156,11 @@ export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) =
|
|||||||
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()
|
||||||
|
? "Erstelle… (Abenteuertext wird verarbeitet, kann etwas dauern)"
|
||||||
|
: "Erstelle…"
|
||||||
|
: "Erstellen"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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,5 +1,5 @@
|
|||||||
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 } from "../api/games";
|
||||||
@@ -9,6 +9,7 @@ import { 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,7 +17,7 @@ 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);
|
||||||
@@ -162,7 +163,9 @@ export default function GameSession() {
|
|||||||
<div className="mx-auto flex h-[calc(100vh-56px)] max-w-6xl flex-col px-4 py-4">
|
<div className="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 gap-2">
|
||||||
|
|||||||
@@ -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