Add per-game adventure text with RAG-based retrieval
Players can now paste the full text of a freely available adventure when creating a game, and the DM will follow it instead of inventing its own plot/NPCs/locations. Reuses the existing rulebook RAG pipeline (chunking, local embedding) rather than injecting the raw text into every turn, since real adventure modules range from a few pages to hundreds — far beyond what fits in a prompt. - New adventure_chunks table (game-scoped, unlike the global rulebook_chunks) + Game.adventure_text storing the raw source. - ingest_adventure_text() chunks/embeds synchronously during POST /api/games when adventure_text is non-empty. - build_adventure_rag_block() retrieves by similarity to the latest player message, scoped to game_id — plus always includes chunk 0 (the adventure's opening) regardless of the query, since early Session-0 messages rarely resemble the adventure's actual hook and pure similarity search could miss the beginning entirely. - System prompt instructs the DM to follow provided adventure excerpts strictly, deviating only when players clearly go off-script. - Frontend: optional adventure-text field in the game creation form, a has_adventure flag surfaced as a small badge on the game card and session header. Verified with an isolated two-game test that chunks/retrieval never leak across games (the critical failure mode here), and live against x.ai: a custom-written one-shot's specific NPCs, location, and hook appeared verbatim in the DM's opening scene instead of invented ones. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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')
|
||||||
@@ -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]
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from app.llm.json_utils import fix_double_escaped_unicode
|
|||||||
from app.llm.tools import character_sheet, dice, end_game, monster, world_state
|
from app.llm.tools import character_sheet, dice, end_game, monster, world_state
|
||||||
from app.models.game import Game
|
from app.models.game import Game
|
||||||
from app.models.message import Message
|
from app.models.message import Message
|
||||||
|
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")
|
||||||
@@ -102,6 +103,10 @@ async def run_dm_turn(
|
|||||||
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))
|
||||||
|
|
||||||
|
|||||||
@@ -33,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.
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
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
|
||||||
@@ -5,4 +6,13 @@ 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
|
from app.models.world_state import WorldState
|
||||||
|
|
||||||
__all__ = ["User", "Game", "GameParticipant", "Character", "Message", "RulebookChunk", "WorldState"]
|
__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)
|
||||||
@@ -27,6 +27,9 @@ class Game(Base):
|
|||||||
# DM-only bookkeeping (name/current_hp/max_hp per monster) — never serialized to any
|
# 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.
|
# API response, since HP/AC of NPCs is meant to stay secret from players.
|
||||||
monsters: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
|
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,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):
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -47,8 +48,11 @@ export const runGameSetupChat = (messages: Record<string, unknown>[], signal?: A
|
|||||||
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}`);
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export default function GameCard({ game }: { game: Game }) {
|
|||||||
{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>}
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) =
|
|||||||
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 [adventureText, setAdventureText] = useState("");
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -76,7 +77,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.");
|
||||||
@@ -120,13 +121,30 @@ 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}
|
||||||
/>
|
/>
|
||||||
|
<div>
|
||||||
|
<textarea
|
||||||
|
placeholder="Abenteuertext (optional) — 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={6}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-slate-500">
|
||||||
|
Wenn hier etwas steht, hält sich der Dungeon Master strikt an diesen Text (Handlung, NPCs,
|
||||||
|
Orte, Encounter) statt eigene Inhalte zu erfinden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
{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()
|
||||||
|
? "Erstelle… (Abenteuertext wird verarbeitet, kann etwas dauern)"
|
||||||
|
: "Erstelle…"
|
||||||
|
: "Erstellen"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -163,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">
|
||||||
|
|||||||
Reference in New Issue
Block a user