Files
DungeonsDragons/frontend/src/pages/CharacterDetail.tsx
T
Thorsten 624644619c 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>
2026-09-01 15:18:15 +02:00

153 lines
5.4 KiB
TypeScript

import { useEffect, useState } from "react";
import { Link, useParams, useSearchParams } from "react-router-dom";
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() {
const { characterId } = useParams<{ characterId: string }>();
const [searchParams] = useSearchParams();
const backGameId = searchParams.get("back");
const [character, setCharacter] = useState<Character | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!characterId) return;
getCharacter(characterId)
.then(setCharacter)
.finally(() => setLoading(false));
}, [characterId]);
if (loading) return <p className="p-8 text-slate-400">Lädt…</p>;
if (!character) return <p className="p-8 text-slate-400">Charakter nicht gefunden.</p>;
return (
<div className="mx-auto max-w-6xl px-4 py-8">
{backGameId && (
<Link
to={`/games/${backGameId}`}
className="mb-4 inline-block rounded bg-slate-800 px-3 py-2 text-sm hover:bg-slate-700"
>
← Zurück zum Spiel
</Link>
)}
<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">
{character.race ?? "?"} · {character.char_class ?? "?"} · Stufe {character.level}
</p>
</div>
{Object.keys(character.stats).length > 0 && (
<div className="mt-6">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">
Attribute
</h2>
<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>
)}
{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 &amp; 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."}
</div>
</div>
</div>
);
}