Files
Michess/client/src/app/tournament/[code]/page.tsx
Michess 157c14c11e feat(bots): add named bot profiles with ELO-based difficulty
- 5 bot profiles (Holzpferd Heinz to Meister Magnus, ELO 200-1400) in
  server/bots.ts and client/bots.ts with emoji + descriptions
- Bot users auto-created in DB on server startup (role='bot')
- AI page replaced difficulty buttons with bot profile cards
- Tournament host can add bots via dropdown (POST /:code/add-bot)
- Bot auto-moves triggered on joinLobby and after each human move
- Fixed clock start order so bot-as-white games initialize correctly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 13:32:43 +02:00

246 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import InviteFriendsModal from "@/components/InviteFriendsModal";
import { useSession } from "@/context/session";
import { fetchTournament, joinTournament, startTournament, addBotToTournament } from "@/lib/tournament";
import { BOTS } from "@/bots";
import type { Tournament, TournamentRound } from "@michess/types";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useEffect, useRef, useState } from "react";
export default function TournamentDetailPage() {
const { user } = useSession();
const params = useParams();
const code = params.code as string;
const [tournament, setTournament] = useState<Tournament | null>(null);
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState(false);
const [selectedBot, setSelectedBot] = useState(BOTS[0].name);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
async function load() {
const t = await fetchTournament(code);
setTournament(t);
}
useEffect(() => {
load().then(() => setLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [code]);
// Auto-refresh every 10 seconds when active
useEffect(() => {
if (tournament?.status === "active") {
intervalRef.current = setInterval(load, 10000);
}
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tournament?.status]);
async function handleJoin() {
if (!user?.id) return;
setActionLoading(true);
const updated = await joinTournament(code);
if (updated) setTournament(updated);
setActionLoading(false);
}
async function handleAddBot() {
setActionLoading(true);
const updated = await addBotToTournament(code, selectedBot);
if (updated) setTournament(updated);
setActionLoading(false);
}
async function handleStart() {
if (!user?.id) return;
setActionLoading(true);
const updated = await startTournament(code);
if (updated) setTournament(updated);
setActionLoading(false);
}
function statusBadge(status: Tournament["status"]) {
if (status === "waiting") return <span className="badge badge-warning">Wartet auf Spieler</span>;
if (status === "active") return <span className="badge badge-success">Aktiv</span>;
return <span className="badge badge-ghost">Beendet</span>;
}
function resultLabel(r: TournamentRound) {
if (!r.result) return <span className="opacity-40"></span>;
if (r.result === "draw") return <span className="badge badge-ghost badge-sm">Remis</span>;
return (
<span className="badge badge-success badge-sm">
{r.result === "white" ? r.whiteName : r.blackName}
</span>
);
}
if (loading) {
return (
<div className="flex justify-center py-16">
<span className="loading loading-spinner loading-lg"></span>
</div>
);
}
if (!tournament) {
return <div className="flex justify-center py-16 text-error">Turnier nicht gefunden.</div>;
}
const isHost = tournament.hostId === user?.id;
const isJoined = tournament.players?.some((p) => p.userId === user?.id);
const canStart = isHost && tournament.status === "waiting" && (tournament.players?.length ?? 0) >= 2;
// Group rounds
const rounds: Record<number, TournamentRound[]> = {};
for (const r of tournament.rounds ?? []) {
if (!rounds[r.round!]) rounds[r.round!] = [];
rounds[r.round!].push(r);
}
return (
<div className="flex flex-col gap-6 w-full max-w-2xl px-4 py-8">
{/* Header */}
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<h1 className="text-2xl font-bold">{tournament.name}</h1>
<div className="flex items-center gap-2 mt-1">
{statusBadge(tournament.status)}
{tournament.timeControl
? <span className="text-sm opacity-60">{tournament.timeControl} Min/Spieler</span>
: <span className="text-sm opacity-60">Keine Uhr</span>}
{tournament.status === "active" && (
<span className="text-sm opacity-60">Runde {tournament.currentRound}/{tournament.totalRounds}</span>
)}
</div>
</div>
<div className="flex gap-2 flex-wrap">
{tournament.status === "waiting" && user?.id && !isJoined && (
<button
className={"btn btn-secondary btn-sm" + (actionLoading ? " loading" : "")}
onClick={handleJoin}
disabled={actionLoading}
>
Beitreten
</button>
)}
{tournament.status === "waiting" && isJoined && (
<InviteFriendsModal gameCode={`tournament/${code}`} label="Freunde einladen" />
)}
{isHost && tournament.status === "waiting" && (
<div className="flex gap-2 items-center">
<select
className="select select-sm select-bordered"
value={selectedBot}
onChange={(e) => setSelectedBot(e.target.value)}
disabled={actionLoading}
>
{BOTS.map((b) => (
<option key={b.name} value={b.name}>{b.emoji} {b.name} ({b.elo})</option>
))}
</select>
<button
className={"btn btn-outline btn-sm" + (actionLoading ? " loading" : "")}
onClick={handleAddBot}
disabled={actionLoading}
>
Bot hinzufügen
</button>
</div>
)}
{canStart && (
<button
className={"btn btn-primary btn-sm" + (actionLoading ? " loading" : "")}
onClick={handleStart}
disabled={actionLoading}
>
Turnier starten
</button>
)}
</div>
</div>
{/* Standings */}
<div className="card bg-base-200 shadow-sm">
<div className="card-body py-3 px-4">
<h2 className="font-bold text-lg mb-2">Tabelle</h2>
{!tournament.players?.length ? (
<p className="text-sm opacity-60">Noch keine Spieler.</p>
) : (
<table className="table table-sm w-full">
<thead>
<tr>
<th>#</th>
<th>Spieler</th>
<th className="text-right">Punkte</th>
<th className="text-right">Partien</th>
</tr>
</thead>
<tbody>
{tournament.players.map((p, i) => (
<tr key={p.userId} className={p.userId === user?.id ? "bg-base-300" : ""}>
<td>{i + 1}</td>
<td>{p.userName}</td>
<td className="text-right font-mono">{p.score}</td>
<td className="text-right opacity-60">{p.gamesPlayed}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
{/* Rounds */}
{Object.keys(rounds).length > 0 && (
<div className="flex flex-col gap-4">
{Object.entries(rounds).map(([roundNum, games]) => (
<div key={roundNum} className="card bg-base-200 shadow-sm">
<div className="card-body py-3 px-4">
<h3 className="font-semibold mb-2">
Runde {roundNum}
{parseInt(roundNum) === tournament.currentRound && tournament.status === "active" && (
<span className="badge badge-success badge-sm ml-2">Aktuell</span>
)}
</h3>
<div className="flex flex-col gap-2">
{games.map((g) => {
const isMyGame = g.whiteId === user?.id || g.blackId === user?.id;
return (
<div key={g.id} className="flex items-center justify-between gap-2 text-sm">
<span>
<span className={g.whiteId === user?.id ? "font-bold" : ""}>{g.whiteName}</span>
<span className="opacity-40 mx-2">vs</span>
<span className={g.blackId === user?.id ? "font-bold" : ""}>{g.blackName}</span>
</span>
<div className="flex items-center gap-2">
{resultLabel(g)}
{isMyGame && g.gameCode && !g.result && (
<Link href={`/${g.gameCode}`} className="btn btn-xs btn-primary">
Spielen
</Link>
)}
{g.gameCode && g.result && (
<Link href={`/${g.gameCode}`} className="btn btn-xs btn-ghost">
Ansehen
</Link>
)}
</div>
</div>
);
})}
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}