Files
DungeonsDragons/frontend/src/components/GameCard.tsx
T
Thorsten b77f066259 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>
2026-09-02 13:13:59 +02:00

66 lines
2.4 KiB
TypeScript

import { useState } from "react";
import { Link } from "react-router-dom";
import { Game } from "../api/games";
export default function GameCard({ game }: { game: Game }) {
const created = new Date(game.created_at).toLocaleString("de-DE");
const [copied, setCopied] = useState(false);
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);
}
return (
<Link
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>
{game.status === "ended" && (
<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 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>
{game.has_adventure && <span>📖 Eigenes Abenteuer</span>}
</div>
</Link>
);
}