Add character detail, markdown rendering, wider layout, and LLM provider switch
- Character sheets gain structured combat_stats, abilities (name + plain-language explanation), and equipment fields, populated by the upsert_character_sheet tool; the detail page renders them as their own sections instead of one prose block, with a player-card-style attribute grid (full ability names + modifiers). - Fix double-escaped unicode occasionally left in tool-call JSON arguments (a model quirk) and stop the app's own json.dumps calls from re-introducing it (ensure_ascii=False). - Render chat messages as markdown (react-markdown + Tailwind typography) instead of raw text, and widen the games/characters/ chat layout to use more of the screen on desktop. - Sharpen the DM system prompt: state the ability + DC for each offered action option before the player commits, and narrate rolls as DC → result → pass/fail before the story consequence; also stop re-asking Session-0 questions already answered in the game's name/description. - Make the DM's LLM provider configurable (xai default, lmstudio for a local OpenAI-compatible server via host.docker.internal) instead of hardcoded to x.ai. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+13
-1
@@ -1,7 +1,19 @@
|
|||||||
# Copy this file to .env and fill in real values. Never commit .env.
|
# Copy this file to .env and fill in real values. Never commit .env.
|
||||||
|
|
||||||
# x.ai API (Grok) — DM model. Get a key at https://console.x.ai
|
# DM model provider: "xai" (default, cloud) or "lmstudio" (local, free, no API cost)
|
||||||
|
LLM_PROVIDER=xai
|
||||||
|
|
||||||
|
# x.ai API (Grok) — used when LLM_PROVIDER=xai. Get a key at https://console.x.ai
|
||||||
XAI_API_KEY=
|
XAI_API_KEY=
|
||||||
|
DM_MODEL=grok-4.6
|
||||||
|
|
||||||
|
# LM Studio local server — used when LLM_PROVIDER=lmstudio. Use host.docker.internal, not a
|
||||||
|
# LAN IP — the backend runs inside Docker and can't necessarily reach the host's LAN address
|
||||||
|
# (e.g. a WSL2-internal IP the host itself sees but containers can't route to).
|
||||||
|
LMSTUDIO_BASE_URL=http://host.docker.internal:1234/v1
|
||||||
|
LMSTUDIO_API_KEY=
|
||||||
|
# When LLM_PROVIDER=lmstudio, also set DM_MODEL above to the model id LM Studio reports at
|
||||||
|
# GET /v1/models, e.g. DM_MODEL=qwen2.5-14b-instruct
|
||||||
|
|
||||||
# Auth secrets — generate with: python -c "import secrets; print(secrets.token_urlsafe(32))"
|
# Auth secrets — generate with: python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||||
JWT_SECRET=
|
JWT_SECRET=
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""add character combat_stats, abilities, equipment fields
|
||||||
|
|
||||||
|
Revision ID: b8e4f1a9c203
|
||||||
|
Revises: 7c3f9a2e1d44
|
||||||
|
Create Date: 2026-09-01 10: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 = 'b8e4f1a9c203'
|
||||||
|
down_revision: Union[str, None] = '7c3f9a2e1d44'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
'characters',
|
||||||
|
sa.Column(
|
||||||
|
'combat_stats', postgresql.JSONB(astext_type=sa.Text()), nullable=False,
|
||||||
|
server_default='{}',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
'characters',
|
||||||
|
sa.Column(
|
||||||
|
'abilities', postgresql.JSONB(astext_type=sa.Text()), nullable=False,
|
||||||
|
server_default='[]',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
'characters',
|
||||||
|
sa.Column(
|
||||||
|
'equipment', postgresql.JSONB(astext_type=sa.Text()), nullable=False,
|
||||||
|
server_default='[]',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('characters', 'equipment')
|
||||||
|
op.drop_column('characters', 'abilities')
|
||||||
|
op.drop_column('characters', 'combat_stats')
|
||||||
@@ -6,10 +6,19 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
database_url: str = "postgresql+asyncpg://dnd:changeme@postgres:5432/dnd"
|
database_url: str = "postgresql+asyncpg://dnd:changeme@postgres:5432/dnd"
|
||||||
|
|
||||||
|
# "xai" (default, cloud) or "lmstudio" (local, OpenAI-compatible server)
|
||||||
|
llm_provider: str = "xai"
|
||||||
|
|
||||||
xai_api_key: str = ""
|
xai_api_key: str = ""
|
||||||
xai_base_url: str = "https://api.x.ai/v1"
|
xai_base_url: str = "https://api.x.ai/v1"
|
||||||
dm_model: str = "grok-4.6"
|
dm_model: str = "grok-4.6"
|
||||||
|
|
||||||
|
# host.docker.internal, not a LAN IP — the backend runs inside Docker Desktop's own
|
||||||
|
# network namespace, which can reach the Windows host via this name but not necessarily
|
||||||
|
# via whatever IP the host itself sees for the machine (e.g. a WSL2-internal address).
|
||||||
|
lmstudio_base_url: str = "http://host.docker.internal:1234/v1"
|
||||||
|
lmstudio_api_key: str = ""
|
||||||
|
|
||||||
jwt_secret: str = "dev-insecure-secret-change-me"
|
jwt_secret: str = "dev-insecure-secret-change-me"
|
||||||
reset_password_secret: str = "dev-insecure-secret-change-me"
|
reset_password_secret: str = "dev-insecure-secret-change-me"
|
||||||
verification_token_secret: str = "dev-insecure-secret-change-me"
|
verification_token_secret: str = "dev-insecure-secret-change-me"
|
||||||
|
|||||||
@@ -20,4 +20,9 @@ def get_game_setup_prompt() -> str:
|
|||||||
|
|
||||||
@lru_cache
|
@lru_cache
|
||||||
def get_llm_client() -> AsyncOpenAI:
|
def get_llm_client() -> AsyncOpenAI:
|
||||||
|
if settings.llm_provider == "lmstudio":
|
||||||
|
# LM Studio's local server accepts any non-empty string when no key is configured.
|
||||||
|
return AsyncOpenAI(
|
||||||
|
api_key=settings.lmstudio_api_key or "lm-studio", base_url=settings.lmstudio_base_url
|
||||||
|
)
|
||||||
return AsyncOpenAI(api_key=settings.xai_api_key, base_url=settings.xai_base_url)
|
return AsyncOpenAI(api_key=settings.xai_api_key, base_url=settings.xai_base_url)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import json
|
|||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.llm.client import get_game_setup_prompt, get_llm_client
|
from app.llm.client import get_game_setup_prompt, get_llm_client
|
||||||
|
from app.llm.json_utils import fix_double_escaped_unicode
|
||||||
from app.llm.tools import game_setup as game_setup_tool
|
from app.llm.tools import game_setup as game_setup_tool
|
||||||
|
|
||||||
MAX_ROUNDS = 3
|
MAX_ROUNDS = 3
|
||||||
@@ -39,14 +40,14 @@ async def run_game_setup_turn(client_messages: list[dict]) -> dict:
|
|||||||
|
|
||||||
messages.append(message.model_dump(exclude_unset=True))
|
messages.append(message.model_dump(exclude_unset=True))
|
||||||
for tool_call in message.tool_calls:
|
for tool_call in message.tool_calls:
|
||||||
args = json.loads(tool_call.function.arguments)
|
args = fix_double_escaped_unicode(json.loads(tool_call.function.arguments))
|
||||||
if tool_call.function.name == "propose_game_setup":
|
if tool_call.function.name == "propose_game_setup":
|
||||||
proposal = {"name": args.get("name", ""), "description": args.get("description", "")}
|
proposal = {"name": args.get("name", ""), "description": args.get("description", "")}
|
||||||
messages.append(
|
messages.append(
|
||||||
{
|
{
|
||||||
"role": "tool",
|
"role": "tool",
|
||||||
"tool_call_id": tool_call.id,
|
"tool_call_id": tool_call.id,
|
||||||
"content": json.dumps({"status": "ok"}),
|
"content": json.dumps({"status": "ok"}, ensure_ascii=False),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if proposal:
|
if proposal:
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
# Some models occasionally leave literal backslash-u escape sequences un-decoded inside
|
||||||
|
# tool-call argument strings (a known JSON-generation quirk) — e.g. the string ends up
|
||||||
|
# containing the six characters ü instead of the character ü. json.loads() alone
|
||||||
|
# doesn't catch this because the escaping happened *inside* an already-valid JSON string,
|
||||||
|
# not at the JSON structural level.
|
||||||
|
_DOUBLE_ESCAPED_UNICODE_RE = re.compile(r"\\u([0-9a-fA-F]{4})")
|
||||||
|
|
||||||
|
|
||||||
|
def _fix_string(text: str) -> str:
|
||||||
|
if "\\u" not in text:
|
||||||
|
return text
|
||||||
|
return _DOUBLE_ESCAPED_UNICODE_RE.sub(lambda m: chr(int(m.group(1), 16)), text)
|
||||||
|
|
||||||
|
|
||||||
|
def fix_double_escaped_unicode(value):
|
||||||
|
"""Recursively repair double-escaped unicode in a parsed JSON value (dict/list/str)."""
|
||||||
|
if isinstance(value, str):
|
||||||
|
return _fix_string(value)
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [fix_double_escaped_unicode(v) for v in value]
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {k: fix_double_escaped_unicode(v) for k, v in value.items()}
|
||||||
|
return value
|
||||||
@@ -7,6 +7,7 @@ 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.tools import character_sheet, dice
|
from app.llm.tools import character_sheet, dice
|
||||||
from app.models.game import Game
|
from app.models.game import Game
|
||||||
from app.models.message import Message
|
from app.models.message import Message
|
||||||
@@ -81,13 +82,13 @@ async def run_dm_turn(
|
|||||||
messages.append(message.model_dump(exclude_unset=True))
|
messages.append(message.model_dump(exclude_unset=True))
|
||||||
|
|
||||||
for tool_call in message.tool_calls:
|
for tool_call in message.tool_calls:
|
||||||
tool_input = json.loads(tool_call.function.arguments)
|
tool_input = fix_double_escaped_unicode(json.loads(tool_call.function.arguments))
|
||||||
result = await _execute_tool_call(session, game_id, tool_call.function.name, tool_input)
|
result = await _execute_tool_call(session, game_id, tool_call.function.name, tool_input)
|
||||||
messages.append(
|
messages.append(
|
||||||
{
|
{
|
||||||
"role": "tool",
|
"role": "tool",
|
||||||
"tool_call_id": tool_call.id,
|
"tool_call_id": tool_call.id,
|
||||||
"content": json.dumps(result),
|
"content": json.dumps(result, ensure_ascii=False),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ Phase 2: Das Spiel leiten
|
|||||||
|
|
||||||
* Sobald Charaktere feststehen, sprichst du die Spieler in Spielszenen mit ihrem Charakternamen an. Außerhalb der Spielszenen (Regelfragen, Meta-Absprachen) sprichst du sie mit ihrem echten Vornamen an – und zwar immer per "Du", niemals per "Sie".
|
* Sobald Charaktere feststehen, sprichst du die Spieler in Spielszenen mit ihrem Charakternamen an. Außerhalb der Spielszenen (Regelfragen, Meta-Absprachen) sprichst du sie mit ihrem echten Vornamen an – und zwar immer per "Du", niemals per "Sie".
|
||||||
* Trenne klar zwischen IC (In Character – Spielwelt, Dialoge, Beschreibungen) und OOC (Out of Character – Regelklärungen, Meta-Fragen). Kennzeichne OOC-Kommentare deutlich, z. B. mit "(OOC: ...)".
|
* Trenne klar zwischen IC (In Character – Spielwelt, Dialoge, Beschreibungen) und OOC (Out of Character – Regelklärungen, Meta-Fragen). Kennzeichne OOC-Kommentare deutlich, z. B. mit "(OOC: ...)".
|
||||||
* Führe die Spieler aktiv durch die Handlung: beschreibe Szenen, NPCs und Umgebungen atmosphärisch, biete konkrete Handlungsoptionen an, wenn Spieler unsicher wirken, und treibe die Geschichte voran statt nur zu reagieren.
|
* Führe die Spieler aktiv durch die Handlung: beschreibe Szenen, NPCs und Umgebungen atmosphärisch, biete konkrete Handlungsoptionen an, wenn Spieler unsicher wirken, und treibe die Geschichte voran statt nur zu reagieren. Erfordert eine Option eine Probe, nenne die betroffene Fähigkeit und den Schwierigkeitsgrad direkt bei der Option (z. B. „B) Die Läden zuwerfen (Stärke, SG 12)"), damit der Spieler seine Chancen vorher einschätzen kann.
|
||||||
* Für Würfelproben: Sage klar, welcher Wurf nötig ist (z. B. "Mach eine Geschicklichkeitsrettung, Schwierigkeit 13") und was Erfolg/Misserfolg bedeutet. Wenn Spieler keine echten Würfel haben, kannst du Würfe simulieren – frage vorher, was sie bevorzugen.
|
* Für Würfelproben: Nenne IMMER zuerst klar, welche Fähigkeit/Fertigkeit gewürfelt wird und welcher Schwierigkeitsgrad (SG) nötig ist – bevor gewürfelt wird. Nutze für jeden spielrelevanten Wurf das roll_dice-Werkzeug, nie ein erfundenes Ergebnis. Erkläre das Ergebnis danach ausführlich in dieser Reihenfolge: (1) benötigter SG, (2) gewürfeltes Ergebnis inklusive Bonus, (3) ob es reicht oder nicht – erst danach folgt die erzählerische Konsequenz. Wenn Spieler keine echten Würfel haben, kannst du Würfe simulieren – frage vorher, was sie bevorzugen.
|
||||||
* Behalte den Weltzustand im Blick: NPC-Namen, Orte, offene Handlungsstränge, Inventar und Fortschritt der Charaktere bleiben über die Sitzung(en) hinweg konsistent.
|
* Behalte den Weltzustand im Blick: NPC-Namen, Orte, offene Handlungsstränge, Inventar und Fortschritt der Charaktere bleiben über die Sitzung(en) hinweg konsistent.
|
||||||
* 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.
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ TOOL_SCHEMA = {
|
|||||||
"name": "upsert_character_sheet",
|
"name": "upsert_character_sheet",
|
||||||
"description": (
|
"description": (
|
||||||
"Create or update a player's character sheet as facts about them are confirmed or change during play "
|
"Create or update a player's character sheet as facts about them are confirmed or change during play "
|
||||||
"(name, race, class, ability stats, abilities, backstory). Call this proactively as soon as details are "
|
"(name, race, class, ability stats, combat stats, abilities, equipment, backstory). Call this "
|
||||||
"settled — do not wait until the end of the session."
|
"proactively as soon as details are settled — do not wait until the end of the session."
|
||||||
),
|
),
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -32,9 +32,39 @@ TOOL_SCHEMA = {
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Ability scores, e.g. {\"STR\": 15, \"DEX\": 12, \"CON\": 14, \"INT\": 10, \"WIS\": 8, \"CHA\": 13}",
|
"description": "Ability scores, e.g. {\"STR\": 15, \"DEX\": 12, \"CON\": 14, \"INT\": 10, \"WIS\": 8, \"CHA\": 13}",
|
||||||
},
|
},
|
||||||
|
"combat_stats": {
|
||||||
|
"type": "object",
|
||||||
|
"description": (
|
||||||
|
"Key/value readouts, e.g. {\"HP\": \"13/13\", \"RK\": \"19\", \"Initiative\": \"+1\", "
|
||||||
|
"\"Tempo\": \"7,5 m\", \"Übungsbonus\": \"+2\"}. Send the full current set each time — "
|
||||||
|
"this replaces whatever was stored before."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"abilities": {
|
||||||
|
"type": "array",
|
||||||
|
"description": (
|
||||||
|
"Every notable class feature, racial trait, feat, or spell the character has — one "
|
||||||
|
"entry per ability. Each 'description' must be a clear, beginner-friendly explanation "
|
||||||
|
"of what it does and when/how to use it, not just its rules name. Send the full current "
|
||||||
|
"list each time — this replaces whatever was stored before."
|
||||||
|
),
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string"},
|
||||||
|
"description": {"type": "string"},
|
||||||
|
},
|
||||||
|
"required": ["name", "description"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"equipment": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Full current equipment/inventory list, one item per string. Replaces whatever was stored before.",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
},
|
||||||
"description": {
|
"description": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Free-text description: special abilities, oaths, backstory, anything relevant.",
|
"description": "Backstory/background narrative only — oaths, personality, motivation, current situation. Abilities and equipment go in their own fields, not here.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"required": ["player_name"],
|
"required": ["player_name"],
|
||||||
@@ -77,6 +107,12 @@ async def upsert(session: AsyncSession, game_id: uuid.UUID, tool_input: dict) ->
|
|||||||
setattr(character, field, tool_input[field])
|
setattr(character, field, tool_input[field])
|
||||||
if "stats" in tool_input and tool_input["stats"] is not None:
|
if "stats" in tool_input and tool_input["stats"] is not None:
|
||||||
character.stats = {**(character.stats or {}), **tool_input["stats"]}
|
character.stats = {**(character.stats or {}), **tool_input["stats"]}
|
||||||
|
if "combat_stats" in tool_input and tool_input["combat_stats"] is not None:
|
||||||
|
character.combat_stats = {**(character.combat_stats or {}), **tool_input["combat_stats"]}
|
||||||
|
if "abilities" in tool_input and tool_input["abilities"] is not None:
|
||||||
|
character.abilities = tool_input["abilities"]
|
||||||
|
if "equipment" in tool_input and tool_input["equipment"] is not None:
|
||||||
|
character.equipment = tool_input["equipment"]
|
||||||
|
|
||||||
await session.flush()
|
await session.flush()
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ class Character(Base):
|
|||||||
level: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
level: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||||
stats: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
stats: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||||
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||||
|
combat_stats: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||||
|
abilities: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
|
||||||
|
equipment: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
|
||||||
created_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False)
|
created_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False)
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
server_default=func.now(), onupdate=func.now(), nullable=False
|
server_default=func.now(), onupdate=func.now(), nullable=False
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ class CharacterRead(BaseModel):
|
|||||||
char_class: str | None
|
char_class: str | None
|
||||||
level: int
|
level: int
|
||||||
stats: dict
|
stats: dict
|
||||||
|
combat_stats: dict
|
||||||
|
abilities: list
|
||||||
|
equipment: list
|
||||||
description: str
|
description: str
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|||||||
Generated
+1201
-79
File diff suppressed because it is too large
Load Diff
@@ -9,8 +9,10 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tailwindcss/typography": "^0.5.20",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^6.28.0"
|
"react-router-dom": "^6.28.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { apiFetch } from "./client";
|
import { apiFetch } from "./client";
|
||||||
|
|
||||||
|
export interface CharacterAbility {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Character {
|
export interface Character {
|
||||||
id: string;
|
id: string;
|
||||||
owner_id: string;
|
owner_id: string;
|
||||||
@@ -8,6 +13,9 @@ export interface Character {
|
|||||||
char_class: string | null;
|
char_class: string | null;
|
||||||
level: number;
|
level: number;
|
||||||
stats: Record<string, number>;
|
stats: Record<string, number>;
|
||||||
|
combat_stats: Record<string, string>;
|
||||||
|
abilities: CharacterAbility[];
|
||||||
|
equipment: string[];
|
||||||
description: string;
|
description: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { FormEvent, useEffect, useRef, useState } from "react";
|
import { FormEvent, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { createGame, Game, GameSetupProposal, runGameSetupChat } from "../api/games";
|
import { createGame, Game, GameSetupProposal, runGameSetupChat } from "../api/games";
|
||||||
|
import MarkdownContent from "./MarkdownContent";
|
||||||
import SendMessageForm from "./SendMessageForm";
|
import SendMessageForm from "./SendMessageForm";
|
||||||
|
|
||||||
interface DisplayTurn {
|
interface DisplayTurn {
|
||||||
@@ -94,8 +95,8 @@ export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) =
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{turn.role === "assistant" ? "Dungeon Master" : "Du"}:
|
{turn.role === "assistant" ? "Dungeon Master" : "Du"}:
|
||||||
</span>{" "}
|
</span>
|
||||||
<span className="whitespace-pre-wrap">{turn.content}</span>
|
<MarkdownContent content={turn.content} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{loading && <p className="text-sm text-slate-500">Der Dungeon Master überlegt…</p>}
|
{loading && <p className="text-sm text-slate-500">Der Dungeon Master überlegt…</p>}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
|
||||||
|
export default function MarkdownContent({ content }: { content: string }) {
|
||||||
|
return (
|
||||||
|
<div className="prose prose-sm prose-invert max-w-none prose-p:my-1 prose-ul:my-1 prose-ol:my-1 prose-li:my-0 prose-headings:my-1">
|
||||||
|
<ReactMarkdown>{content}</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
import { Message } from "../api/games";
|
import { Message } from "../api/games";
|
||||||
|
import MarkdownContent from "./MarkdownContent";
|
||||||
|
|
||||||
function senderLabel(message: Message): string {
|
function senderLabel(message: Message): string {
|
||||||
if (message.sender_type === "dm") return "Dungeon Master";
|
if (message.sender_type === "dm") return "Dungeon Master";
|
||||||
@@ -36,8 +37,8 @@ export default function TranscriptPane({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{senderLabel(m)}:
|
{senderLabel(m)}:
|
||||||
</span>{" "}
|
</span>
|
||||||
<span className="whitespace-pre-wrap">{m.content}</span>
|
<MarkdownContent content={m.content} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{typing && (
|
{typing && (
|
||||||
|
|||||||
@@ -3,6 +3,30 @@ import { Link, useParams, useSearchParams } from "react-router-dom";
|
|||||||
|
|
||||||
import { Character, getCharacter } from "../api/characters";
|
import { Character, getCharacter } from "../api/characters";
|
||||||
|
|
||||||
|
const ABILITY_NAMES: Record<string, string> = {
|
||||||
|
STR: "Stärke",
|
||||||
|
DEX: "Geschicklichkeit",
|
||||||
|
CON: "Konstitution",
|
||||||
|
INT: "Intelligenz",
|
||||||
|
WIS: "Weisheit",
|
||||||
|
CHA: "Charisma",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ABILITY_ORDER = ["STR", "DEX", "CON", "INT", "WIS", "CHA"];
|
||||||
|
|
||||||
|
function orderedStats(stats: Record<string, number>): [string, number][] {
|
||||||
|
const known = ABILITY_ORDER.filter((key) => key in stats).map(
|
||||||
|
(key) => [key, stats[key]] as [string, number],
|
||||||
|
);
|
||||||
|
const unknown = Object.entries(stats).filter(([key]) => !ABILITY_ORDER.includes(key));
|
||||||
|
return [...known, ...unknown];
|
||||||
|
}
|
||||||
|
|
||||||
|
function modifier(score: number): string {
|
||||||
|
const mod = Math.floor((score - 10) / 2);
|
||||||
|
return mod >= 0 ? `+${mod}` : `${mod}`;
|
||||||
|
}
|
||||||
|
|
||||||
export default function CharacterDetail() {
|
export default function CharacterDetail() {
|
||||||
const { characterId } = useParams<{ characterId: string }>();
|
const { characterId } = useParams<{ characterId: string }>();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
@@ -22,7 +46,7 @@ export default function CharacterDetail() {
|
|||||||
if (!character) return <p className="p-8 text-slate-400">Charakter nicht gefunden.</p>;
|
if (!character) return <p className="p-8 text-slate-400">Charakter nicht gefunden.</p>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-2xl px-4 py-8">
|
<div className="mx-auto max-w-6xl px-4 py-8">
|
||||||
{backGameId && (
|
{backGameId && (
|
||||||
<Link
|
<Link
|
||||||
to={`/games/${backGameId}`}
|
to={`/games/${backGameId}`}
|
||||||
@@ -32,25 +56,97 @@ export default function CharacterDetail() {
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<h1 className="text-2xl font-bold text-amber-400">{character.name}</h1>
|
<div className="rounded-lg border border-amber-500/30 bg-slate-900 p-6">
|
||||||
|
<h1 className="text-3xl font-bold text-amber-400">{character.name}</h1>
|
||||||
<p className="mt-1 text-slate-400">
|
<p className="mt-1 text-slate-400">
|
||||||
{character.race ?? "?"} · {character.char_class ?? "?"} · Stufe {character.level}
|
{character.race ?? "?"} · {character.char_class ?? "?"} · Stufe {character.level}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{Object.keys(character.stats).length > 0 && (
|
{Object.keys(character.stats).length > 0 && (
|
||||||
<div className="mt-4 grid grid-cols-3 gap-3 sm:grid-cols-6">
|
<div className="mt-6">
|
||||||
{Object.entries(character.stats).map(([stat, value]) => (
|
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">
|
||||||
<div key={stat} className="rounded bg-slate-800 p-2 text-center">
|
Attribute
|
||||||
<div className="text-xs uppercase text-slate-500">{stat}</div>
|
</h2>
|
||||||
<div className="text-lg font-semibold text-slate-100">{value}</div>
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||||
|
{orderedStats(character.stats).map(([key, value]) => (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className="flex flex-col items-center rounded-lg border border-slate-700 bg-slate-800 py-4"
|
||||||
|
>
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-slate-400">
|
||||||
|
{ABILITY_NAMES[key] ?? key}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-3xl font-bold text-slate-100">{value}</div>
|
||||||
|
<div className="mt-1 rounded-full bg-slate-700 px-2 py-0.5 text-sm font-semibold text-amber-400">
|
||||||
|
{modifier(value)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-6 whitespace-pre-wrap rounded-lg border border-slate-800 bg-slate-900 p-4 text-slate-300">
|
{Object.keys(character.combat_stats).length > 0 && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">
|
||||||
|
Kampfwerte
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{Object.entries(character.combat_stats).map(([key, value]) => (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className="rounded-lg border border-slate-700 bg-slate-800 px-4 py-2 text-center"
|
||||||
|
>
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-slate-400">
|
||||||
|
{key}
|
||||||
|
</div>
|
||||||
|
<div className="text-lg font-bold text-slate-100">{value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{character.abilities.length > 0 && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">
|
||||||
|
Fähigkeiten & Merkmale
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{character.abilities.map((ability, i) => (
|
||||||
|
<div key={i} className="rounded-lg border border-slate-800 bg-slate-900 p-4">
|
||||||
|
<h3 className="font-semibold text-amber-400">{ability.name}</h3>
|
||||||
|
<p className="mt-1 whitespace-pre-wrap text-sm text-slate-300">
|
||||||
|
{ability.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{character.equipment.length > 0 && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">
|
||||||
|
Ausrüstung
|
||||||
|
</h2>
|
||||||
|
<ul className="list-disc rounded-lg border border-slate-800 bg-slate-900 p-4 pl-8 text-slate-300">
|
||||||
|
{character.equipment.map((item, i) => (
|
||||||
|
<li key={i}>{item}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">
|
||||||
|
Hintergrundgeschichte
|
||||||
|
</h2>
|
||||||
|
<div className="whitespace-pre-wrap rounded-lg border border-slate-800 bg-slate-900 p-4 text-slate-300">
|
||||||
{character.description || "Noch keine Beschreibung."}
|
{character.description || "Noch keine Beschreibung."}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export default function CharactersList() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-3xl 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>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-slate-400">Lädt…</p>
|
<p className="text-slate-400">Lädt…</p>
|
||||||
@@ -23,7 +23,7 @@ export default function CharactersList() {
|
|||||||
Noch keine Charaktere — sie entstehen im Laufe eines Spiels.
|
Noch keine Charaktere — sie entstehen im Laufe eines Spiels.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<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} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export default function ForgotPassword() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto mt-16 max-w-sm px-4">
|
<div className="mx-auto mt-16 max-w-md px-4">
|
||||||
<h1 className="mb-6 text-2xl font-bold text-amber-400">Passwort vergessen</h1>
|
<h1 className="mb-6 text-2xl font-bold text-amber-400">Passwort vergessen</h1>
|
||||||
{sent ? (
|
{sent ? (
|
||||||
<p className="text-slate-300">
|
<p className="text-slate-300">
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export default function GameSession() {
|
|||||||
|
|
||||||
if (!game.is_participant) {
|
if (!game.is_participant) {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto mt-16 max-w-sm px-4">
|
<div className="mx-auto mt-16 max-w-md px-4">
|
||||||
<h1 className="mb-2 text-2xl font-bold text-amber-400">{game.name}</h1>
|
<h1 className="mb-2 text-2xl font-bold text-amber-400">{game.name}</h1>
|
||||||
<p className="mb-6 text-sm text-slate-400">{game.description}</p>
|
<p className="mb-6 text-sm text-slate-400">{game.description}</p>
|
||||||
<form onSubmit={handleJoin} className="flex flex-col gap-4">
|
<form onSubmit={handleJoin} className="flex flex-col gap-4">
|
||||||
@@ -152,7 +152,7 @@ export default function GameSession() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex h-[calc(100vh-56px)] max-w-3xl 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}</h1>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export default function GamesList() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-3xl 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">
|
||||||
<h1 className="text-2xl font-bold text-amber-400">Spiele</h1>
|
<h1 className="text-2xl font-bold text-amber-400">Spiele</h1>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -45,7 +45,7 @@ export default function GamesList() {
|
|||||||
) : games.length === 0 ? (
|
) : games.length === 0 ? (
|
||||||
<p className="text-slate-400">Noch keine Spiele vorhanden.</p>
|
<p className="text-slate-400">Noch keine Spiele vorhanden.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-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} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export default function Login() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto mt-16 max-w-sm px-4">
|
<div className="mx-auto mt-16 max-w-md px-4">
|
||||||
<h1 className="mb-6 text-2xl font-bold text-amber-400">Anmelden</h1>
|
<h1 className="mb-6 text-2xl font-bold text-amber-400">Anmelden</h1>
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export default function Register() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto mt-16 max-w-sm px-4">
|
<div className="mx-auto mt-16 max-w-md px-4">
|
||||||
<h1 className="mb-6 text-2xl font-bold text-amber-400">Registrieren</h1>
|
<h1 className="mb-6 text-2xl font-bold text-amber-400">Registrieren</h1>
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export default function ResetPassword() {
|
|||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto mt-16 max-w-sm px-4 text-slate-300">
|
<div className="mx-auto mt-16 max-w-md px-4 text-slate-300">
|
||||||
Kein gültiger Reset-Link.{" "}
|
Kein gültiger Reset-Link.{" "}
|
||||||
<Link to="/forgot-password" className="text-amber-400">
|
<Link to="/forgot-password" className="text-amber-400">
|
||||||
Neu anfordern
|
Neu anfordern
|
||||||
@@ -35,7 +35,7 @@ export default function ResetPassword() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto mt-16 max-w-sm px-4">
|
<div className="mx-auto mt-16 max-w-md px-4">
|
||||||
<h1 className="mb-6 text-2xl font-bold text-amber-400">Neues Passwort setzen</h1>
|
<h1 className="mb-6 text-2xl font-bold text-amber-400">Neues Passwort setzen</h1>
|
||||||
{done ? (
|
{done ? (
|
||||||
<p className="text-slate-300">Passwort geändert. Du wirst weitergeleitet…</p>
|
<p className="text-slate-300">Passwort geändert. Du wirst weitergeleitet…</p>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import typography from "@tailwindcss/typography";
|
||||||
|
|
||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
export default {
|
export default {
|
||||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {},
|
extend: {},
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [typography],
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user