624644619c
- 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>
153 lines
5.4 KiB
TypeScript
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 & 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>
|
|
);
|
|
}
|