b77f066259
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>
66 lines
2.4 KiB
TypeScript
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>
|
|
);
|
|
}
|