feat: add time controls, correspondence games, and tournament mode
**Zeitmodi (Blitz/Rapid):** - 5, 10, 30 Min Zeitkontrolle bei Spielerstellung - Server-seitige Uhren: Zeit wird bei jedem Zug abgezogen - Timeout-Erkennung via claimTimeout Socket-Event - Schachuhr-Anzeige in GamePage (rot bei < 30s) - Turnierspiele erhalten automatisch Zeitkontrolle **Tagespartien (Correspondence):** - Neue DB-Tabelle correspondence_game - REST API: erstellen, beitreten, Zug machen, aufgeben - Seite /correspondence: Liste aktiver Tagespartien - Seite /correspondence/[code]: Spielen mit interaktivem Brett - Einladungslink teilen, Gegner tritt via Link bei **Turniermodus (Round-Robin):** - Neue DB-Tabellen: tournament, tournament_player, tournament_round - Round-Robin Paarungen mit Circle-Methode - Turnier erstellen: /tournament/create - Turnierliste: /tournament - Turnierseite /tournament/[code]: Standings, Runden, Spiele - Automatische Aktivisierung der nächsten Runde - Turnierspiele werden als normale activeGames erstellt - Ergebnisse aktualisieren Punkte automatisch Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
249
client/src/app/correspondence/[code]/page.tsx
Normal file
249
client/src/app/correspondence/[code]/page.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
"use client";
|
||||
|
||||
import { useSession } from "@/context/session";
|
||||
import {
|
||||
fetchCorrespondenceGame,
|
||||
joinCorrespondenceGame,
|
||||
makeCorrespondenceMove,
|
||||
resignCorrespondenceGame,
|
||||
} from "@/lib/correspondence";
|
||||
import type { CorrespondenceGame } from "@michess/types";
|
||||
import { Chess } from "chess.js";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Chessboard } from "react-chessboard";
|
||||
import type { Square } from "chess.js";
|
||||
import { APP_URL } from "@/config";
|
||||
|
||||
export default function CorrespondenceGamePage() {
|
||||
const { user } = useSession();
|
||||
const params = useParams();
|
||||
const code = params.code as string;
|
||||
|
||||
const [game, setGame] = useState<CorrespondenceGame | null>(null);
|
||||
const [chess] = useState(() => new Chess());
|
||||
const [fen, setFen] = useState("start");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [moveFrom, setMoveFrom] = useState<string | null>(null);
|
||||
const [joining, setJoining] = useState(false);
|
||||
const [resigning, setResigning] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const loadGame = useCallback(async () => {
|
||||
const g = await fetchCorrespondenceGame(code);
|
||||
if (!g) return;
|
||||
setGame(g);
|
||||
chess.reset();
|
||||
if (g.pgn) chess.loadPgn(g.pgn);
|
||||
setFen(chess.fen());
|
||||
}, [code, chess]);
|
||||
|
||||
useEffect(() => {
|
||||
loadGame().then(() => setLoading(false));
|
||||
}, [loadGame]);
|
||||
|
||||
const isWhite = game?.white?.id === user?.id;
|
||||
const isBlack = game?.black?.id === user?.id;
|
||||
const isPlayer = isWhite || isBlack;
|
||||
const myTurn = isPlayer && !game?.winner && (
|
||||
(chess.turn() === "w" && isWhite) || (chess.turn() === "b" && isBlack)
|
||||
);
|
||||
|
||||
async function handleSquareClick(square: Square) {
|
||||
if (!myTurn || game?.winner) return;
|
||||
|
||||
if (moveFrom === null) {
|
||||
const piece = chess.get(square);
|
||||
if (!piece) return;
|
||||
const isMyPiece = (chess.turn() === "w" && piece.color === "w") || (chess.turn() === "b" && piece.color === "b");
|
||||
if (!isMyPiece) return;
|
||||
setMoveFrom(square);
|
||||
return;
|
||||
}
|
||||
|
||||
if (moveFrom === square) {
|
||||
setMoveFrom(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = await makeCorrespondenceMove(code, moveFrom, square);
|
||||
setMoveFrom(null);
|
||||
if (updated) {
|
||||
setGame(updated);
|
||||
chess.reset();
|
||||
if (updated.pgn) chess.loadPgn(updated.pgn);
|
||||
setFen(chess.fen());
|
||||
} else {
|
||||
// Try as from-square instead
|
||||
const piece = chess.get(square);
|
||||
if (piece) setMoveFrom(square);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDrop(from: Square, to: Square) {
|
||||
if (!myTurn || game?.winner) return false;
|
||||
makeCorrespondenceMove(code, from, to).then((updated) => {
|
||||
if (updated) {
|
||||
setGame(updated);
|
||||
chess.reset();
|
||||
if (updated.pgn) chess.loadPgn(updated.pgn);
|
||||
setFen(chess.fen());
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
async function handleJoin() {
|
||||
if (!user?.id) return;
|
||||
setJoining(true);
|
||||
const updated = await joinCorrespondenceGame(code);
|
||||
if (updated) setGame(updated);
|
||||
setJoining(false);
|
||||
}
|
||||
|
||||
async function handleResign() {
|
||||
if (!isPlayer || game?.winner) return;
|
||||
setResigning(true);
|
||||
const updated = await resignCorrespondenceGame(code);
|
||||
if (updated) setGame(updated);
|
||||
setResigning(false);
|
||||
}
|
||||
|
||||
function copyInvite() {
|
||||
navigator.clipboard.writeText(`${APP_URL}/correspondence/${code}`).catch(() => {});
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 3000);
|
||||
}
|
||||
|
||||
function getMoveList() {
|
||||
const history = chess.history({ verbose: true });
|
||||
const pairs: { w: string; b?: string }[] = [];
|
||||
for (let i = 0; i < history.length; i += 2) {
|
||||
pairs.push({ w: history[i].san, b: history[i + 1]?.san });
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center py-16">
|
||||
<span className="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!game) {
|
||||
return <div className="flex justify-center py-16 text-error">Partie nicht gefunden.</div>;
|
||||
}
|
||||
|
||||
const boardOrientation = isBlack ? "black" : "white";
|
||||
const winnerName = game.winner === "white" ? game.white?.name : game.winner === "black" ? game.black?.name : null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap justify-center gap-6 px-4 py-8">
|
||||
<div>
|
||||
<Chessboard
|
||||
boardWidth={480}
|
||||
position={fen}
|
||||
boardOrientation={boardOrientation}
|
||||
customDarkSquareStyle={{ backgroundColor: "#4b7399" }}
|
||||
customLightSquareStyle={{ backgroundColor: "#eae9d2" }}
|
||||
isDraggablePiece={({ piece }) =>
|
||||
isPlayer && !game.winner &&
|
||||
((chess.turn() === "w" && piece.startsWith("w") && isWhite) ||
|
||||
(chess.turn() === "b" && piece.startsWith("b") && isBlack))
|
||||
}
|
||||
onSquareClick={handleSquareClick}
|
||||
onPieceDrop={handleDrop}
|
||||
customSquareStyles={moveFrom ? { [moveFrom]: { background: "rgba(255,255,0,0.4)" } } : {}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 max-w-sm w-full">
|
||||
{/* Players */}
|
||||
<div className="card bg-base-200 shadow-sm">
|
||||
<div className="card-body py-3 px-4 gap-1">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-bold">{game.white?.name ?? "?"}</span>
|
||||
<span className="text-xs opacity-60">Weiß</span>
|
||||
</div>
|
||||
<div className="text-center text-sm opacity-50">vs</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-bold">{game.black?.name ?? "Wartet auf Gegner"}</span>
|
||||
<span className="text-xs opacity-60">Schwarz</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status / winner banner */}
|
||||
{game.winner ? (
|
||||
<div className="alert alert-success">
|
||||
<span>
|
||||
{game.winner === "draw"
|
||||
? "Remis!"
|
||||
: `${winnerName} gewinnt (${game.endReason === "checkmate" ? "Matt" : game.endReason === "resign" ? "Aufgabe" : game.endReason})!`}
|
||||
</span>
|
||||
</div>
|
||||
) : !game.black?.id ? (
|
||||
<div className="card bg-base-200 shadow-sm">
|
||||
<div className="card-body py-3 px-4 gap-2">
|
||||
<p className="text-sm">Warte auf Gegner. Einladungslink:</p>
|
||||
<button
|
||||
className={"btn btn-sm btn-outline" + (copied ? " btn-success" : "")}
|
||||
onClick={copyInvite}
|
||||
>
|
||||
{copied ? "Kopiert!" : `${APP_URL.replace(/^https?:\/\//, "")}/correspondence/${code}`}
|
||||
</button>
|
||||
{!isPlayer && user?.id && (
|
||||
<button
|
||||
className={"btn btn-primary btn-sm" + (joining ? " loading" : "")}
|
||||
onClick={handleJoin}
|
||||
disabled={joining}
|
||||
>
|
||||
Beitreten
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="alert alert-info">
|
||||
<span>
|
||||
{myTurn ? "Du bist dran!" : `${chess.turn() === "w" ? game.white?.name : game.black?.name} ist dran.`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Move list */}
|
||||
<div className="card bg-base-200 shadow-sm">
|
||||
<div className="card-body py-3 px-4">
|
||||
<h3 className="font-semibold text-sm mb-1">Züge</h3>
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
<table className="table table-xs w-full">
|
||||
<tbody>
|
||||
{getMoveList().map((pair, i) => (
|
||||
<tr key={i}>
|
||||
<td className="opacity-50 w-6">{i + 1}.</td>
|
||||
<td>{pair.w}</td>
|
||||
<td>{pair.b ?? ""}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resign button */}
|
||||
{isPlayer && !game.winner && game.black?.id && (
|
||||
<button
|
||||
className={"btn btn-error btn-outline btn-sm" + (resigning ? " loading" : "")}
|
||||
onClick={handleResign}
|
||||
disabled={resigning}
|
||||
>
|
||||
Aufgeben
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
118
client/src/app/correspondence/page.tsx
Normal file
118
client/src/app/correspondence/page.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import { useSession } from "@/context/session";
|
||||
import { createCorrespondenceGame, fetchMyCorrespondenceGames } from "@/lib/correspondence";
|
||||
import type { CorrespondenceGame } from "@michess/types";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function CorrespondencePage() {
|
||||
const { user } = useSession();
|
||||
const [games, setGames] = useState<CorrespondenceGame[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [daysPerMove, setDaysPerMove] = useState(3);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.id) return;
|
||||
fetchMyCorrespondenceGames().then((g) => {
|
||||
setGames(g);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [user?.id]);
|
||||
|
||||
async function handleCreate() {
|
||||
if (!user?.id) return;
|
||||
setCreating(true);
|
||||
const game = await createCorrespondenceGame(daysPerMove);
|
||||
if (game) {
|
||||
setGames((prev) => [game, ...prev]);
|
||||
}
|
||||
setCreating(false);
|
||||
}
|
||||
|
||||
function getTurnLabel(game: CorrespondenceGame): string {
|
||||
if (game.winner) {
|
||||
if (game.winner === "draw") return "Remis";
|
||||
return `${game.winner === "white" ? game.white?.name : game.black?.name} hat gewonnen`;
|
||||
}
|
||||
if (!game.black?.id) return "Warte auf Gegner...";
|
||||
const chess = new (require("chess.js").Chess)();
|
||||
if (game.pgn) chess.loadPgn(game.pgn);
|
||||
const turn = chess.turn();
|
||||
const turnName = turn === "w" ? game.white?.name : game.black?.name;
|
||||
if (turn === "w" && game.white?.id === user?.id) return "Du bist dran";
|
||||
if (turn === "b" && game.black?.id === user?.id) return "Du bist dran";
|
||||
return `${turnName} ist dran`;
|
||||
}
|
||||
|
||||
if (!user?.id) {
|
||||
return (
|
||||
<div className="flex flex-col items-center py-16 gap-4">
|
||||
<p className="text-lg">Bitte einloggen um Tagespartien zu spielen.</p>
|
||||
<Link href="/auth/login" className="btn btn-primary">Einloggen</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 w-full max-w-2xl px-4 py-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Tagespartien</h1>
|
||||
</div>
|
||||
|
||||
<div className="card bg-base-200 shadow">
|
||||
<div className="card-body gap-3">
|
||||
<h2 className="card-title text-lg">Neue Partie erstellen</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-sm">Tage pro Zug:</label>
|
||||
<select
|
||||
className="select select-bordered select-sm"
|
||||
value={daysPerMove}
|
||||
onChange={(e) => setDaysPerMove(parseInt(e.target.value))}
|
||||
>
|
||||
<option value={1}>1 Tag</option>
|
||||
<option value={2}>2 Tage</option>
|
||||
<option value={3}>3 Tage</option>
|
||||
<option value={5}>5 Tage</option>
|
||||
<option value={7}>7 Tage</option>
|
||||
</select>
|
||||
<button
|
||||
className={"btn btn-primary btn-sm" + (creating ? " loading" : "")}
|
||||
onClick={handleCreate}
|
||||
disabled={creating}
|
||||
>
|
||||
Erstellen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<span className="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
) : games.length === 0 ? (
|
||||
<div className="text-center opacity-60 py-8">Keine aktiven Tagespartien.</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{games.map((game) => (
|
||||
<div key={game.code} className="card bg-base-200 shadow-sm">
|
||||
<div className="card-body flex-row items-center justify-between py-3 px-4">
|
||||
<div>
|
||||
<div className="font-semibold">
|
||||
{game.white?.name ?? "?"} vs {game.black?.name ?? "Warte auf Gegner"}
|
||||
</div>
|
||||
<div className="text-sm opacity-70">{getTurnLabel(game)}</div>
|
||||
</div>
|
||||
<Link href={`/correspondence/${game.code}`} className="btn btn-sm btn-primary">
|
||||
Spielen
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,11 +14,11 @@ export default function Home() {
|
||||
<div className="flex flex-col gap-8 w-full max-w-5xl px-4 py-8">
|
||||
|
||||
{/* Hauptaktionen */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
|
||||
<div className="card bg-base-200 border-2 border-primary shadow-lg">
|
||||
<div className="card-body gap-3">
|
||||
<h2 className="card-title text-primary">♟ Spiel erstellen</h2>
|
||||
<h2 className="card-title text-primary">Spiel erstellen</h2>
|
||||
<p className="text-sm opacity-70">Erstelle eine Partie und teile den Einladungslink mit Freunden</p>
|
||||
<div className="mt-2">
|
||||
<CreateGame />
|
||||
@@ -28,7 +28,7 @@ export default function Home() {
|
||||
|
||||
<div className="card bg-base-200 shadow-lg">
|
||||
<div className="card-body gap-3">
|
||||
<h2 className="card-title">🤖 Gegen KI spielen</h2>
|
||||
<h2 className="card-title">Gegen KI spielen</h2>
|
||||
<p className="text-sm opacity-70">Fordere Stockfish auf verschiedenen Stufen heraus — von Anfänger bis Meister</p>
|
||||
<div className="mt-auto pt-4">
|
||||
<Link href="/ai" className="btn btn-secondary w-full">Jetzt spielen</Link>
|
||||
@@ -40,7 +40,7 @@ export default function Home() {
|
||||
<div className="card-body gap-3">
|
||||
{isLoggedIn ? (
|
||||
<>
|
||||
<h2 className="card-title">👥 Freunde</h2>
|
||||
<h2 className="card-title">Freunde</h2>
|
||||
<p className="text-sm opacity-70">Freunde hinzufügen, verwalten und zu einer Partie einladen</p>
|
||||
<div className="mt-auto pt-4">
|
||||
<Link href="/friends" className="btn btn-outline w-full">Freunde verwalten</Link>
|
||||
@@ -48,7 +48,7 @@ export default function Home() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="card-title">🔗 Einladung annehmen</h2>
|
||||
<h2 className="card-title">Einladung annehmen</h2>
|
||||
<p className="text-sm opacity-70 mb-2">Einladungslink oder Code eingeben um beizutreten</p>
|
||||
<JoinGame />
|
||||
</>
|
||||
@@ -56,6 +56,26 @@ export default function Home() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card bg-base-200 shadow-lg">
|
||||
<div className="card-body gap-3">
|
||||
<h2 className="card-title">Tagespartien</h2>
|
||||
<p className="text-sm opacity-70">Spiele in deinem eigenen Tempo — Züge bis zu 3 Tage Zeit</p>
|
||||
<div className="mt-auto pt-4">
|
||||
<Link href="/correspondence" className="btn btn-outline w-full">Tagespartien</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card bg-base-200 shadow-lg">
|
||||
<div className="card-body gap-3">
|
||||
<h2 className="card-title">Turnier</h2>
|
||||
<p className="text-sm opacity-70">Spiele Round-Robin Turniere gegen mehrere Gegner</p>
|
||||
<div className="mt-auto pt-4">
|
||||
<Link href="/tournament" className="btn btn-outline w-full">Turniere</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Öffentliche Spiele + Beitreten */}
|
||||
|
||||
211
client/src/app/tournament/[code]/page.tsx
Normal file
211
client/src/app/tournament/[code]/page.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
"use client";
|
||||
|
||||
import { useSession } from "@/context/session";
|
||||
import { fetchTournament, joinTournament, startTournament } from "@/lib/tournament";
|
||||
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 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 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">
|
||||
{tournament.status === "waiting" && user?.id && !isJoined && (
|
||||
<button
|
||||
className={"btn btn-secondary btn-sm" + (actionLoading ? " loading" : "")}
|
||||
onClick={handleJoin}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
Beitreten
|
||||
</button>
|
||||
)}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
107
client/src/app/tournament/create/page.tsx
Normal file
107
client/src/app/tournament/create/page.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { useSession } from "@/context/session";
|
||||
import { createTournament } from "@/lib/tournament";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { FormEvent } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function CreateTournamentPage() {
|
||||
const { user } = useSession();
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
if (!user?.id) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const form = e.target as HTMLFormElement;
|
||||
const name = (form.elements.namedItem("name") as HTMLInputElement).value.trim();
|
||||
const timeControlVal = parseInt((form.elements.namedItem("timeControl") as HTMLSelectElement).value);
|
||||
const maxPlayers = parseInt((form.elements.namedItem("maxPlayers") as HTMLSelectElement).value);
|
||||
|
||||
if (!name) {
|
||||
setError("Bitte einen Namen eingeben.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const tournament = await createTournament(name, timeControlVal > 0 ? timeControlVal : undefined, maxPlayers);
|
||||
if (tournament?.code) {
|
||||
router.push(`/tournament/${tournament.code}`);
|
||||
} else {
|
||||
setError("Turnier konnte nicht erstellt werden.");
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!user?.id) {
|
||||
return (
|
||||
<div className="flex flex-col items-center py-16 gap-4">
|
||||
<p className="text-lg">Bitte einloggen um ein Turnier zu erstellen.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 w-full max-w-md px-4 py-8">
|
||||
<h1 className="text-2xl font-bold">Turnier erstellen</h1>
|
||||
|
||||
<form className="card bg-base-200 shadow" onSubmit={handleSubmit}>
|
||||
<div className="card-body gap-4">
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text">Name</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
className="input input-bordered"
|
||||
placeholder="z.B. MiChess Open 2026"
|
||||
maxLength={128}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text">Bedenkzeit</span>
|
||||
</label>
|
||||
<select name="timeControl" className="select select-bordered">
|
||||
<option value="0">Keine Uhr</option>
|
||||
<option value="5">5 Min (Blitz)</option>
|
||||
<option value="10">10 Min (Blitz)</option>
|
||||
<option value="30">30 Min (Rapid)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text">Max. Spieler</span>
|
||||
</label>
|
||||
<select name="maxPlayers" className="select select-bordered" defaultValue="8">
|
||||
<option value="4">4</option>
|
||||
<option value="6">6</option>
|
||||
<option value="8">8</option>
|
||||
<option value="12">12</option>
|
||||
<option value="16">16</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && <div className="alert alert-error text-sm">{error}</div>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className={"btn btn-primary" + (loading ? " loading" : "")}
|
||||
disabled={loading}
|
||||
>
|
||||
Erstellen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
97
client/src/app/tournament/page.tsx
Normal file
97
client/src/app/tournament/page.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { useSession } from "@/context/session";
|
||||
import { fetchTournaments, joinTournament } from "@/lib/tournament";
|
||||
import type { Tournament } from "@michess/types";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function TournamentPage() {
|
||||
const { user } = useSession();
|
||||
const [tournaments, setTournaments] = useState<Tournament[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [joiningCode, setJoiningCode] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTournaments().then((t) => {
|
||||
setTournaments(t);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
async function handleJoin(code: string) {
|
||||
if (!user?.id) return;
|
||||
setJoiningCode(code);
|
||||
const updated = await joinTournament(code);
|
||||
if (updated) {
|
||||
setTournaments((prev) => prev.map((t) => (t.code === code ? updated : t)));
|
||||
}
|
||||
setJoiningCode(null);
|
||||
}
|
||||
|
||||
function statusBadge(status: Tournament["status"]) {
|
||||
if (status === "waiting") return <span className="badge badge-warning badge-sm">Wartet</span>;
|
||||
if (status === "active") return <span className="badge badge-success badge-sm">Aktiv</span>;
|
||||
return <span className="badge badge-ghost badge-sm">Beendet</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 w-full max-w-2xl px-4 py-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Turniere</h1>
|
||||
{user?.id && (
|
||||
<Link href="/tournament/create" className="btn btn-primary btn-sm">
|
||||
Turnier erstellen
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<span className="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
) : tournaments.length === 0 ? (
|
||||
<div className="text-center opacity-60 py-8">Keine aktiven Turniere.</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{tournaments.map((t) => {
|
||||
const isJoined = t.players?.some((p) => p.userId === user?.id);
|
||||
return (
|
||||
<div key={t.code} className="card bg-base-200 shadow-sm">
|
||||
<div className="card-body py-3 px-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold">{t.name}</span>
|
||||
{statusBadge(t.status)}
|
||||
</div>
|
||||
<div className="text-xs opacity-60">
|
||||
{t.players?.length ?? 0}/{t.maxPlayers} Spieler
|
||||
{t.timeControl ? ` · ${t.timeControl} Min` : " · Keine Uhr"}
|
||||
{t.status === "active" ? ` · Runde ${t.currentRound}/${t.totalRounds}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{t.status === "waiting" && user?.id && !isJoined && (
|
||||
<button
|
||||
className={"btn btn-sm btn-secondary" + (joiningCode === t.code ? " loading" : "")}
|
||||
onClick={() => handleJoin(t.code!)}
|
||||
disabled={joiningCode === t.code}
|
||||
>
|
||||
Beitreten
|
||||
</button>
|
||||
)}
|
||||
<Link href={`/tournament/${t.code}`} className="btn btn-sm btn-outline">
|
||||
Details
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -54,6 +54,11 @@ export default function GamePage({ initialLobby }: { initialLobby: Game }) {
|
||||
const [navFen, setNavFen] = useState<string | null>(null);
|
||||
const [navIndex, setNavIndex] = useState<number | null>(null);
|
||||
|
||||
const [clockWhite, setClockWhite] = useState<number | null>(null);
|
||||
const [clockBlack, setClockBlack] = useState<number | null>(null);
|
||||
const clockRef = useRef<{ whiteTimeMs: number; blackTimeMs: number; clientLastMoveAt: number } | null>(null);
|
||||
const lobbyRef = useRef(lobby);
|
||||
|
||||
const [playBtnLoading, setPlayBtnLoading] = useState(false);
|
||||
const [copiedLink, setCopiedLink] = useState(false);
|
||||
const [chatMessages, setChatMessages] = useState<Message[]>([
|
||||
@@ -65,6 +70,30 @@ export default function GamePage({ initialLobby }: { initialLobby: Game }) {
|
||||
const chatListRef = useRef<HTMLUListElement>(null);
|
||||
const moveListRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Keep lobbyRef in sync so clock interval can read latest lobby without re-registering
|
||||
useEffect(() => { lobbyRef.current = lobby; }, [lobby]);
|
||||
|
||||
// Clock interval — only active when timeControl is set
|
||||
useEffect(() => {
|
||||
if (!initialLobby.timeControl) return;
|
||||
const interval = setInterval(() => {
|
||||
if (!clockRef.current) return;
|
||||
const l = lobbyRef.current;
|
||||
if (l.endReason || l.winner) return;
|
||||
const elapsed = Date.now() - clockRef.current.clientLastMoveAt;
|
||||
const turn = l.actualGame.turn();
|
||||
const white = turn === "w" ? Math.max(0, clockRef.current.whiteTimeMs - elapsed) : clockRef.current.whiteTimeMs;
|
||||
const black = turn === "b" ? Math.max(0, clockRef.current.blackTimeMs - elapsed) : clockRef.current.blackTimeMs;
|
||||
setClockWhite(white);
|
||||
setClockBlack(black);
|
||||
if ((turn === "w" && white <= 0) || (turn === "b" && black <= 0)) {
|
||||
socket.emit("claimTimeout");
|
||||
}
|
||||
}, 100);
|
||||
return () => clearInterval(interval);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [initialLobby.timeControl]);
|
||||
|
||||
const [abandonSeconds, setAbandonSeconds] = useState(60);
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -114,7 +143,14 @@ export default function GamePage({ initialLobby }: { initialLobby: Game }) {
|
||||
updateCustomSquares,
|
||||
makeMove,
|
||||
setNavFen,
|
||||
setNavIndex
|
||||
setNavIndex,
|
||||
onClockUpdate: ({ whiteTimeMs, blackTimeMs, lastMoveAt }) => {
|
||||
clockRef.current = { whiteTimeMs, blackTimeMs, clientLastMoveAt: Date.now() };
|
||||
setClockWhite(whiteTimeMs);
|
||||
setClockBlack(blackTimeMs);
|
||||
// suppress unused-warning on lastMoveAt — stored via clientLastMoveAt above
|
||||
void lastMoveAt;
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -503,6 +539,12 @@ export default function GamePage({ initialLobby }: { initialLobby: Game }) {
|
||||
};
|
||||
}
|
||||
|
||||
function formatClock(ms: number | null): string {
|
||||
if (ms === null) return "--:--";
|
||||
const s = Math.max(0, Math.ceil(ms / 1000));
|
||||
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function claimAbandoned(type: "win" | "draw") {
|
||||
if (
|
||||
lobby.side === "s" ||
|
||||
@@ -568,6 +610,29 @@ export default function GamePage({ initialLobby }: { initialLobby: Game }) {
|
||||
{getPlayerHtml("bottom")}
|
||||
</div>
|
||||
|
||||
{initialLobby.timeControl && (
|
||||
<div className="flex flex-col items-end justify-between gap-1 pl-2">
|
||||
{/* Top clock = opponent */}
|
||||
<div className={
|
||||
"font-mono text-lg font-bold px-2 py-1 rounded " +
|
||||
(lobby.side === "b"
|
||||
? (clockWhite !== null && clockWhite < 30000 ? "bg-error text-error-content" : "bg-base-300")
|
||||
: (clockBlack !== null && clockBlack < 30000 ? "bg-error text-error-content" : "bg-base-300"))
|
||||
}>
|
||||
{lobby.side === "b" ? formatClock(clockWhite) : formatClock(clockBlack)}
|
||||
</div>
|
||||
{/* Bottom clock = self */}
|
||||
<div className={
|
||||
"font-mono text-lg font-bold px-2 py-1 rounded " +
|
||||
(lobby.side === "b"
|
||||
? (clockBlack !== null && clockBlack < 30000 ? "bg-error text-error-content" : "bg-base-300")
|
||||
: (clockWhite !== null && clockWhite < 30000 ? "bg-error text-error-content" : "bg-base-300"))
|
||||
}>
|
||||
{lobby.side === "b" ? formatClock(clockBlack) : formatClock(clockWhite)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<div className="mb-2 flex w-full flex-col items-end gap-1">
|
||||
{lobby.endReason ? "Archived link:" : "Invite friends:"}
|
||||
|
||||
@@ -16,6 +16,7 @@ export function initSocket(
|
||||
makeMove: Function;
|
||||
setNavFen: Dispatch<SetStateAction<string | null>>;
|
||||
setNavIndex: Dispatch<SetStateAction<number | null>>;
|
||||
onClockUpdate?: (data: { whiteTimeMs: number; blackTimeMs: number; lastMoveAt: number }) => void;
|
||||
}
|
||||
) {
|
||||
socket.on("connect", () => {
|
||||
@@ -34,6 +35,18 @@ export function initSocket(
|
||||
actions.updateLobby({ type: "updateLobby", payload: latestGame });
|
||||
|
||||
syncSide(user, latestGame, lobby, actions);
|
||||
|
||||
if (latestGame.whiteTimeMs !== undefined && latestGame.lastMoveAt && actions.onClockUpdate) {
|
||||
actions.onClockUpdate({
|
||||
whiteTimeMs: latestGame.whiteTimeMs,
|
||||
blackTimeMs: latestGame.blackTimeMs ?? 0,
|
||||
lastMoveAt: latestGame.lastMoveAt
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("clockUpdate", (data: { whiteTimeMs: number; blackTimeMs: number; lastMoveAt: number }) => {
|
||||
actions.onClockUpdate?.(data);
|
||||
});
|
||||
|
||||
socket.on("receivedMove", (m: { from: string; to: string; promotion?: string }) => {
|
||||
@@ -75,6 +88,8 @@ export function initSocket(
|
||||
}
|
||||
} else if (reason === "checkmate") {
|
||||
m.message = `${winnerName} (${winnerSide}) has won by checkmate.`;
|
||||
} else if (reason === "timeout") {
|
||||
m.message = `Zeit abgelaufen! ${winnerSide === "white" ? "Weiß" : "Schwarz"} gewinnt.`;
|
||||
} else {
|
||||
let message = "The game has ended in a draw";
|
||||
if (reason === "repetition") {
|
||||
|
||||
@@ -19,10 +19,11 @@ export default function CreateGame() {
|
||||
|
||||
const target = e.target as HTMLFormElement;
|
||||
const unlisted = target.elements.namedItem("createUnlisted") as HTMLInputElement;
|
||||
const startingSide = (target.elements.namedItem("createStartingSide") as HTMLSelectElement)
|
||||
.value;
|
||||
const startingSide = (target.elements.namedItem("createStartingSide") as HTMLSelectElement).value;
|
||||
const timeControlVal = parseInt((target.elements.namedItem("createTimeControl") as HTMLSelectElement).value);
|
||||
const timeControl = timeControlVal > 0 ? timeControlVal : undefined;
|
||||
|
||||
const game = await createGame(startingSide, unlisted.checked);
|
||||
const game = await createGame(startingSide, unlisted.checked, timeControl);
|
||||
|
||||
if (game) {
|
||||
router.push(`/${game.code}`);
|
||||
@@ -38,6 +39,16 @@ export default function CreateGame() {
|
||||
<span className="label-text text-sm">Nur per Einladung</span>
|
||||
<input type="checkbox" className="checkbox checkbox-primary checkbox-sm" name="createUnlisted" id="createUnlisted" />
|
||||
</label>
|
||||
<select
|
||||
className="select select-bordered select-sm w-full"
|
||||
name="createTimeControl"
|
||||
id="createTimeControl"
|
||||
>
|
||||
<option value="0">Keine Uhr</option>
|
||||
<option value="5">5 Min (Blitz)</option>
|
||||
<option value="10">10 Min (Blitz)</option>
|
||||
<option value="30">30 Min (Rapid)</option>
|
||||
</select>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
className="select select-bordered select-sm flex-1"
|
||||
|
||||
46
client/src/lib/correspondence.ts
Normal file
46
client/src/lib/correspondence.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { API_URL } from "@/config";
|
||||
import type { CorrespondenceGame } from "@michess/types";
|
||||
|
||||
export const fetchMyCorrespondenceGames = async (): Promise<CorrespondenceGame[]> => {
|
||||
const res = await fetch(`${API_URL}/v1/correspondence`, { credentials: "include" });
|
||||
if (!res.ok) return [];
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export const fetchCorrespondenceGame = async (code: string): Promise<CorrespondenceGame | null> => {
|
||||
const res = await fetch(`${API_URL}/v1/correspondence/${code}`, { credentials: "include" });
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export const joinCorrespondenceGame = async (code: string): Promise<CorrespondenceGame | null> => {
|
||||
const res = await fetch(`${API_URL}/v1/correspondence/${code}/join`, { method: "POST", credentials: "include" });
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export const makeCorrespondenceMove = async (code: string, from: string, to: string, promotion?: string): Promise<CorrespondenceGame | null> => {
|
||||
const res = await fetch(`${API_URL}/v1/correspondence/${code}/move`, {
|
||||
method: "POST", credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ from, to, promotion })
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export const resignCorrespondenceGame = async (code: string): Promise<CorrespondenceGame | null> => {
|
||||
const res = await fetch(`${API_URL}/v1/correspondence/${code}/resign`, { method: "POST", credentials: "include" });
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export const createCorrespondenceGame = async (daysPerMove = 3): Promise<CorrespondenceGame | null> => {
|
||||
const res = await fetch(`${API_URL}/v1/correspondence`, {
|
||||
method: "POST", credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ daysPerMove })
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { API_URL } from "@/config";
|
||||
import type { Game } from "@michess/types";
|
||||
|
||||
export const createGame = async (side: string, unlisted: boolean) => {
|
||||
export const createGame = async (side: string, unlisted: boolean, timeControl?: number) => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/v1/games`, {
|
||||
method: "POST",
|
||||
@@ -9,7 +9,7 @@ export const createGame = async (side: string, unlisted: boolean) => {
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({ side, unlisted }),
|
||||
body: JSON.stringify({ side, unlisted, timeControl: timeControl || null }),
|
||||
cache: "no-store"
|
||||
});
|
||||
|
||||
|
||||
36
client/src/lib/tournament.ts
Normal file
36
client/src/lib/tournament.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { API_URL } from "@/config";
|
||||
import type { Tournament } from "@michess/types";
|
||||
|
||||
export const fetchTournaments = async (): Promise<Tournament[]> => {
|
||||
const res = await fetch(`${API_URL}/v1/tournaments`, { cache: "no-store" });
|
||||
if (!res.ok) return [];
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export const fetchTournament = async (code: string): Promise<Tournament | null> => {
|
||||
const res = await fetch(`${API_URL}/v1/tournaments/${code}`, { cache: "no-store" });
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export const createTournament = async (name: string, timeControl?: number, maxPlayers?: number): Promise<Tournament | null> => {
|
||||
const res = await fetch(`${API_URL}/v1/tournaments`, {
|
||||
method: "POST", credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, timeControl, maxPlayers })
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export const joinTournament = async (code: string): Promise<Tournament | null> => {
|
||||
const res = await fetch(`${API_URL}/v1/tournaments/${code}/join`, { method: "POST", credentials: "include" });
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export const startTournament = async (code: string): Promise<Tournament | null> => {
|
||||
const res = await fetch(`${API_URL}/v1/tournaments/${code}/start`, { method: "POST", credentials: "include" });
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
};
|
||||
85
server/src/controllers/correspondence.controller.ts
Normal file
85
server/src/controllers/correspondence.controller.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { Request, Response } from "express";
|
||||
import CorrespondenceModel from "../db/models/correspondence.model.js";
|
||||
import { Chess } from "chess.js";
|
||||
|
||||
export const createCorrespondenceGame = async (req: Request, res: Response) => {
|
||||
if (!req.session.user?.id || typeof req.session.user.id === "string") { res.status(401).end(); return; }
|
||||
try {
|
||||
const game = await CorrespondenceModel.create(req.session.user.id as number, req.session.user.name!, req.body.daysPerMove || 3);
|
||||
res.status(201).json(game);
|
||||
} catch (e) { console.error(e); res.status(500).end(); }
|
||||
};
|
||||
|
||||
export const getMyCorrespondenceGames = async (req: Request, res: Response) => {
|
||||
if (!req.session.user?.id || typeof req.session.user.id === "string") { res.status(401).end(); return; }
|
||||
try {
|
||||
const games = await CorrespondenceModel.findByUserId(req.session.user.id as number);
|
||||
res.status(200).json(games);
|
||||
} catch (e) { res.status(500).end(); }
|
||||
};
|
||||
|
||||
export const getCorrespondenceGame = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const game = await CorrespondenceModel.findByCode(req.params.code);
|
||||
if (!game) { res.status(404).end(); return; }
|
||||
res.status(200).json(game);
|
||||
} catch (e) { res.status(500).end(); }
|
||||
};
|
||||
|
||||
export const joinCorrespondenceGame = async (req: Request, res: Response) => {
|
||||
if (!req.session.user?.id || typeof req.session.user.id === "string") { res.status(401).end(); return; }
|
||||
try {
|
||||
const game = await CorrespondenceModel.join(req.params.code, req.session.user.id as number, req.session.user.name!);
|
||||
if (!game) { res.status(400).json({ message: "Spiel nicht verfügbar." }); return; }
|
||||
res.status(200).json(game);
|
||||
} catch (e) { res.status(500).end(); }
|
||||
};
|
||||
|
||||
export const makeCorrespondenceMove = async (req: Request, res: Response) => {
|
||||
if (!req.session.user?.id || typeof req.session.user.id === "string") { res.status(401).end(); return; }
|
||||
try {
|
||||
const { from, to, promotion } = req.body;
|
||||
const game = await CorrespondenceModel.findByCode(req.params.code);
|
||||
if (!game || game.winner || game.endReason) { res.status(400).json({ message: "Spiel beendet." }); return; }
|
||||
if (!game.black?.id) { res.status(400).json({ message: "Kein Gegner." }); return; }
|
||||
|
||||
const chess = new Chess();
|
||||
if (game.pgn) chess.loadPgn(game.pgn);
|
||||
const turn = chess.turn();
|
||||
const userId = req.session.user.id as number;
|
||||
const isWhite = game.white?.id === userId;
|
||||
const isBlack = game.black?.id === userId;
|
||||
|
||||
if (!isWhite && !isBlack) { res.status(403).end(); return; }
|
||||
if ((turn === "w" && !isWhite) || (turn === "b" && !isBlack)) {
|
||||
res.status(403).json({ message: "Nicht dein Zug." }); return;
|
||||
}
|
||||
|
||||
const move = chess.move({ from, to, promotion: promotion || "q" });
|
||||
if (!move) { res.status(400).json({ message: "Ungültiger Zug." }); return; }
|
||||
|
||||
let winner: string | undefined, endReason: string | undefined;
|
||||
if (chess.isGameOver()) {
|
||||
if (chess.isCheckmate()) { endReason = "checkmate"; winner = turn === "w" ? "white" : "black"; }
|
||||
else if (chess.isStalemate()) { endReason = "stalemate"; winner = "draw"; }
|
||||
else if (chess.isThreefoldRepetition()) { endReason = "repetition"; winner = "draw"; }
|
||||
else if (chess.isInsufficientMaterial()) { endReason = "insufficient"; winner = "draw"; }
|
||||
else { endReason = "draw"; winner = "draw"; }
|
||||
}
|
||||
|
||||
const updated = await CorrespondenceModel.applyMove(req.params.code, chess.pgn(), winner, endReason);
|
||||
res.status(200).json(updated);
|
||||
} catch (e) { console.error(e); res.status(500).end(); }
|
||||
};
|
||||
|
||||
export const resignCorrespondenceGame = async (req: Request, res: Response) => {
|
||||
if (!req.session.user?.id || typeof req.session.user.id === "string") { res.status(401).end(); return; }
|
||||
try {
|
||||
const game = await CorrespondenceModel.findByCode(req.params.code);
|
||||
if (!game || game.endReason) { res.status(400).end(); return; }
|
||||
const userId = req.session.user.id as number;
|
||||
if (game.white?.id !== userId && game.black?.id !== userId) { res.status(403).end(); return; }
|
||||
const updated = await CorrespondenceModel.resign(req.params.code, userId);
|
||||
res.status(200).json(updated);
|
||||
} catch (e) { res.status(500).end(); }
|
||||
};
|
||||
@@ -78,11 +78,15 @@ export const createGame = async (req: Request, res: Response) => {
|
||||
connected: false
|
||||
};
|
||||
const unlisted: boolean = req.body.unlisted ?? false;
|
||||
const timeControl: number | undefined = req.body.timeControl ? parseInt(req.body.timeControl) : undefined;
|
||||
const game: Game = {
|
||||
code: nanoid(6),
|
||||
unlisted,
|
||||
host: user,
|
||||
pgn: ""
|
||||
pgn: "",
|
||||
timeControl: timeControl || undefined,
|
||||
whiteTimeMs: timeControl ? timeControl * 60 * 1000 : undefined,
|
||||
blackTimeMs: timeControl ? timeControl * 60 * 1000 : undefined
|
||||
};
|
||||
if (req.body.side === "white") {
|
||||
game.white = user;
|
||||
|
||||
82
server/src/controllers/tournament.controller.ts
Normal file
82
server/src/controllers/tournament.controller.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { Request, Response } from "express";
|
||||
import TournamentModel from "../db/models/tournament.model.js";
|
||||
import { activeGames } from "../db/models/game.model.js";
|
||||
import { nanoid } from "nanoid";
|
||||
import type { Game } from "@michess/types";
|
||||
|
||||
export const createTournament = async (req: Request, res: Response) => {
|
||||
if (!req.session.user?.id || typeof req.session.user.id === "string") { res.status(401).end(); return; }
|
||||
const { name, timeControl, maxPlayers } = req.body;
|
||||
if (!name) { res.status(400).json({ message: "Name erforderlich." }); return; }
|
||||
try {
|
||||
const tournament = await TournamentModel.createTournament(
|
||||
req.session.user.id as number, req.session.user.name!, name,
|
||||
timeControl ? parseInt(timeControl) : undefined, maxPlayers ? parseInt(maxPlayers) : 8
|
||||
);
|
||||
await TournamentModel.joinTournament(tournament.code!, req.session.user.id as number, req.session.user.name!);
|
||||
const updated = await TournamentModel.findByCode(tournament.code!);
|
||||
res.status(201).json(updated);
|
||||
} catch (e) { console.error(e); res.status(500).end(); }
|
||||
};
|
||||
|
||||
export const getTournaments = async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const tournaments = await TournamentModel.findAll();
|
||||
res.status(200).json(tournaments);
|
||||
} catch (e) { res.status(500).end(); }
|
||||
};
|
||||
|
||||
export const getTournament = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const tournament = await TournamentModel.findByCode(req.params.code);
|
||||
if (!tournament) { res.status(404).end(); return; }
|
||||
res.status(200).json(tournament);
|
||||
} catch (e) { res.status(500).end(); }
|
||||
};
|
||||
|
||||
export const joinTournament = async (req: Request, res: Response) => {
|
||||
if (!req.session.user?.id || typeof req.session.user.id === "string") { res.status(401).end(); return; }
|
||||
try {
|
||||
const result = await TournamentModel.joinTournament(req.params.code, req.session.user.id as number, req.session.user.name!);
|
||||
if (!result) { res.status(400).json({ message: "Turnier nicht verfügbar." }); return; }
|
||||
const updated = await TournamentModel.findByCode(req.params.code);
|
||||
res.status(200).json(updated);
|
||||
} catch (e) { res.status(500).end(); }
|
||||
};
|
||||
|
||||
export const startTournament = async (req: Request, res: Response) => {
|
||||
if (!req.session.user?.id || typeof req.session.user.id === "string") { res.status(401).end(); return; }
|
||||
try {
|
||||
const tournament = await TournamentModel.findByCode(req.params.code);
|
||||
if (!tournament) { res.status(404).end(); return; }
|
||||
if (tournament.hostId !== req.session.user.id) { res.status(403).end(); return; }
|
||||
if (tournament.status !== "waiting") { res.status(400).json({ message: "Turnier bereits gestartet." }); return; }
|
||||
if (!tournament.players || tournament.players.length < 2) { res.status(400).json({ message: "Mindestens 2 Spieler erforderlich." }); return; }
|
||||
|
||||
const allRounds = await TournamentModel.startTournament(tournament.id!);
|
||||
if (!allRounds) { res.status(500).end(); return; }
|
||||
|
||||
// Create active games for round 1
|
||||
const round1 = allRounds[0];
|
||||
for (const roundRow of round1) {
|
||||
const code = nanoid(6);
|
||||
const tc = tournament.timeControl;
|
||||
const row = roundRow as any;
|
||||
const whiteUser = { id: row.white_id, name: row.white_name, connected: false };
|
||||
const blackUser = { id: row.black_id, name: row.black_name, connected: false };
|
||||
const game: Game = {
|
||||
code, unlisted: true, host: whiteUser,
|
||||
white: whiteUser, black: blackUser, pgn: "",
|
||||
startedAt: Date.now(),
|
||||
timeControl: tc || undefined,
|
||||
whiteTimeMs: tc ? tc * 60 * 1000 : undefined,
|
||||
blackTimeMs: tc ? tc * 60 * 1000 : undefined
|
||||
};
|
||||
activeGames.push(game);
|
||||
await TournamentModel.setRoundGameCode(row.id, code);
|
||||
}
|
||||
|
||||
const updated = await TournamentModel.findByCode(req.params.code);
|
||||
res.status(200).json(updated);
|
||||
} catch (e) { console.error(e); res.status(500).end(); }
|
||||
};
|
||||
@@ -48,4 +48,57 @@ export const INIT_TABLES = /* sql */ `
|
||||
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS banned BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE "game" ADD COLUMN IF NOT EXISTS vs_ai BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE "game" ADD COLUMN IF NOT EXISTS ai_level INTEGER;
|
||||
ALTER TABLE "game" ADD COLUMN IF NOT EXISTS time_control INTEGER;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "correspondence_game" (
|
||||
id SERIAL PRIMARY KEY,
|
||||
code VARCHAR(8) UNIQUE NOT NULL,
|
||||
white_id INT REFERENCES "user"(id),
|
||||
black_id INT REFERENCES "user"(id),
|
||||
white_name VARCHAR(128),
|
||||
black_name VARCHAR(128),
|
||||
pgn TEXT DEFAULT '',
|
||||
winner VARCHAR(5),
|
||||
end_reason VARCHAR(16),
|
||||
days_per_move INTEGER DEFAULT 3,
|
||||
last_move_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
ended_at TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "tournament" (
|
||||
id SERIAL PRIMARY KEY,
|
||||
code VARCHAR(8) UNIQUE NOT NULL,
|
||||
name VARCHAR(128) NOT NULL,
|
||||
host_id INT REFERENCES "user"(id),
|
||||
host_name VARCHAR(128),
|
||||
status VARCHAR(16) DEFAULT 'waiting',
|
||||
time_control INTEGER,
|
||||
max_players INTEGER DEFAULT 8,
|
||||
current_round INTEGER DEFAULT 0,
|
||||
total_rounds INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "tournament_player" (
|
||||
tournament_id INT REFERENCES "tournament"(id) ON DELETE CASCADE,
|
||||
user_id INT REFERENCES "user"(id) ON DELETE CASCADE,
|
||||
user_name VARCHAR(128),
|
||||
score DECIMAL(4,1) DEFAULT 0,
|
||||
games_played INTEGER DEFAULT 0,
|
||||
PRIMARY KEY (tournament_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "tournament_round" (
|
||||
id SERIAL PRIMARY KEY,
|
||||
tournament_id INT REFERENCES "tournament"(id) ON DELETE CASCADE,
|
||||
round INTEGER NOT NULL,
|
||||
game_code VARCHAR(8),
|
||||
game_id INT,
|
||||
white_id INT REFERENCES "user"(id),
|
||||
white_name VARCHAR(128),
|
||||
black_id INT REFERENCES "user"(id),
|
||||
black_name VARCHAR(128),
|
||||
result VARCHAR(5)
|
||||
);
|
||||
`;
|
||||
|
||||
79
server/src/db/models/correspondence.model.ts
Normal file
79
server/src/db/models/correspondence.model.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { db } from "../index.js";
|
||||
import type { CorrespondenceGame } from "@michess/types";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
function mapRow(r: any): CorrespondenceGame {
|
||||
return {
|
||||
id: r.id,
|
||||
code: r.code,
|
||||
white: { id: r.white_id, name: r.white_name },
|
||||
black: r.black_id ? { id: r.black_id, name: r.black_name } : undefined,
|
||||
pgn: r.pgn || "",
|
||||
winner: r.winner,
|
||||
endReason: r.end_reason,
|
||||
daysPerMove: r.days_per_move,
|
||||
lastMoveAt: r.last_move_at?.getTime(),
|
||||
startedAt: r.started_at?.getTime(),
|
||||
endedAt: r.ended_at?.getTime()
|
||||
};
|
||||
}
|
||||
|
||||
export const create = async (userId: number, userName: string, daysPerMove = 3): Promise<CorrespondenceGame> => {
|
||||
const code = nanoid(8);
|
||||
const res = await db.query(
|
||||
`INSERT INTO "correspondence_game"(code, white_id, white_name, days_per_move) VALUES($1, $2, $3, $4) RETURNING *`,
|
||||
[code, userId, userName, daysPerMove]
|
||||
);
|
||||
return mapRow(res.rows[0]);
|
||||
};
|
||||
|
||||
export const join = async (code: string, userId: number, userName: string): Promise<CorrespondenceGame | null> => {
|
||||
const res = await db.query(
|
||||
`UPDATE "correspondence_game" SET black_id=$1, black_name=$2 WHERE code=$3 AND black_id IS NULL AND white_id != $1 RETURNING *`,
|
||||
[userId, userName, code]
|
||||
);
|
||||
return res.rows[0] ? mapRow(res.rows[0]) : null;
|
||||
};
|
||||
|
||||
export const findByCode = async (code: string): Promise<CorrespondenceGame | null> => {
|
||||
const res = await db.query(`SELECT * FROM "correspondence_game" WHERE code=$1`, [code]);
|
||||
return res.rows[0] ? mapRow(res.rows[0]) : null;
|
||||
};
|
||||
|
||||
export const findByUserId = async (userId: number): Promise<CorrespondenceGame[]> => {
|
||||
const res = await db.query(
|
||||
`SELECT * FROM "correspondence_game" WHERE (white_id=$1 OR black_id=$1) AND ended_at IS NULL ORDER BY last_move_at DESC`,
|
||||
[userId]
|
||||
);
|
||||
return res.rows.map(mapRow);
|
||||
};
|
||||
|
||||
export const applyMove = async (code: string, pgn: string, winner?: string, endReason?: string): Promise<CorrespondenceGame | null> => {
|
||||
let res;
|
||||
if (winner) {
|
||||
res = await db.query(
|
||||
`UPDATE "correspondence_game" SET pgn=$1, winner=$2, end_reason=$3, last_move_at=NOW(), ended_at=NOW() WHERE code=$4 RETURNING *`,
|
||||
[pgn, winner, endReason, code]
|
||||
);
|
||||
} else {
|
||||
res = await db.query(
|
||||
`UPDATE "correspondence_game" SET pgn=$1, last_move_at=NOW() WHERE code=$2 RETURNING *`,
|
||||
[pgn, code]
|
||||
);
|
||||
}
|
||||
return res.rows[0] ? mapRow(res.rows[0]) : null;
|
||||
};
|
||||
|
||||
export const resign = async (code: string, resigningUserId: number): Promise<CorrespondenceGame | null> => {
|
||||
const game = await findByCode(code);
|
||||
if (!game) return null;
|
||||
const winner = game.white?.id === resigningUserId ? "black" : "white";
|
||||
const res = await db.query(
|
||||
`UPDATE "correspondence_game" SET winner=$1, end_reason='resign', ended_at=NOW() WHERE code=$2 RETURNING *`,
|
||||
[winner, code]
|
||||
);
|
||||
return res.rows[0] ? mapRow(res.rows[0]) : null;
|
||||
};
|
||||
|
||||
const CorrespondenceModel = { create, join, findByCode, findByUserId, applyMove, resign };
|
||||
export default CorrespondenceModel;
|
||||
@@ -14,7 +14,7 @@ export const save = async (game: Game) => {
|
||||
black.id = game.black?.id;
|
||||
}
|
||||
const res = await db.query(
|
||||
`INSERT INTO "game"(winner, end_reason, pgn, white_id, white_name, black_id, black_name, started_at, vs_ai, ai_level) VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *`,
|
||||
`INSERT INTO "game"(winner, end_reason, pgn, white_id, white_name, black_id, black_name, started_at, vs_ai, ai_level, time_control) VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *`,
|
||||
[
|
||||
game.winner || null,
|
||||
game.endReason || null,
|
||||
@@ -25,7 +25,8 @@ export const save = async (game: Game) => {
|
||||
black.name || null,
|
||||
new Date(game.startedAt as number),
|
||||
game.vsAi || false,
|
||||
game.aiLevel || null
|
||||
game.aiLevel || null,
|
||||
game.timeControl || null
|
||||
]
|
||||
);
|
||||
if (black.id || white.id) {
|
||||
@@ -66,7 +67,8 @@ export const save = async (game: Game) => {
|
||||
startedAt: res.rows[0].started_at.getTime(),
|
||||
endedAt: res.rows[0].ended_at?.getTime() || undefined,
|
||||
vsAi: res.rows[0].vs_ai || undefined,
|
||||
aiLevel: res.rows[0].ai_level || undefined
|
||||
aiLevel: res.rows[0].ai_level || undefined,
|
||||
timeControl: res.rows[0].time_control || undefined
|
||||
} as Game;
|
||||
} catch (err: unknown) {
|
||||
console.log(err);
|
||||
|
||||
179
server/src/db/models/tournament.model.ts
Normal file
179
server/src/db/models/tournament.model.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { db } from "../index.js";
|
||||
import { nanoid } from "nanoid";
|
||||
import type { Tournament, TournamentPlayer, TournamentRound } from "@michess/types";
|
||||
|
||||
// Circle method for round-robin tournament scheduling
|
||||
function generateRoundRobinPairings(players: TournamentPlayer[]): { white: TournamentPlayer; black: TournamentPlayer }[][] {
|
||||
let ps: (TournamentPlayer | null)[] = [...players];
|
||||
if (ps.length % 2 !== 0) ps.push(null); // BYE slot for odd number
|
||||
const n = ps.length;
|
||||
const rounds: { white: TournamentPlayer; black: TournamentPlayer }[][] = [];
|
||||
|
||||
for (let r = 0; r < n - 1; r++) {
|
||||
const games: { white: TournamentPlayer; black: TournamentPlayer }[] = [];
|
||||
for (let k = 0; k < n / 2; k++) {
|
||||
const home = ps[k];
|
||||
const away = ps[n - 1 - k];
|
||||
if (home && away) {
|
||||
// Alternate colors each round for fairness
|
||||
games.push(r % 2 === 0 ? { white: home, black: away } : { white: away, black: home });
|
||||
}
|
||||
}
|
||||
rounds.push(games);
|
||||
// Rotate: keep index 0 fixed, rotate the rest clockwise
|
||||
const last = ps[n - 1];
|
||||
for (let i = n - 1; i > 1; i--) ps[i] = ps[i - 1];
|
||||
ps[1] = last;
|
||||
}
|
||||
return rounds;
|
||||
}
|
||||
|
||||
function mapTournament(t: any, players: any[], rounds: any[]): Tournament {
|
||||
return {
|
||||
id: t.id,
|
||||
code: t.code,
|
||||
name: t.name,
|
||||
hostId: t.host_id,
|
||||
hostName: t.host_name,
|
||||
status: t.status,
|
||||
timeControl: t.time_control,
|
||||
maxPlayers: t.max_players,
|
||||
currentRound: t.current_round,
|
||||
totalRounds: t.total_rounds,
|
||||
players: players.map(p => ({
|
||||
tournamentId: p.tournament_id,
|
||||
userId: p.user_id,
|
||||
userName: p.user_name,
|
||||
score: parseFloat(p.score),
|
||||
gamesPlayed: p.games_played
|
||||
})),
|
||||
rounds: rounds.map(r => ({
|
||||
id: r.id,
|
||||
tournamentId: r.tournament_id,
|
||||
round: r.round,
|
||||
gameCode: r.game_code,
|
||||
gameId: r.game_id,
|
||||
whiteId: r.white_id,
|
||||
whiteName: r.white_name,
|
||||
blackId: r.black_id,
|
||||
blackName: r.black_name,
|
||||
result: r.result
|
||||
})),
|
||||
createdAt: t.created_at?.getTime()
|
||||
};
|
||||
}
|
||||
|
||||
export const createTournament = async (hostId: number, hostName: string, name: string, timeControl?: number, maxPlayers = 8): Promise<Tournament> => {
|
||||
const code = nanoid(6);
|
||||
const res = await db.query(
|
||||
`INSERT INTO "tournament"(code, name, host_id, host_name, time_control, max_players) VALUES($1,$2,$3,$4,$5,$6) RETURNING *`,
|
||||
[code, name, hostId, hostName, timeControl || null, maxPlayers]
|
||||
);
|
||||
return mapTournament(res.rows[0], [], []);
|
||||
};
|
||||
|
||||
export const joinTournament = async (code: string, userId: number, userName: string): Promise<boolean | null> => {
|
||||
const t = await findByCode(code);
|
||||
if (!t || t.status !== "waiting") return null;
|
||||
if ((t.players?.length ?? 0) >= (t.maxPlayers ?? 8)) return null;
|
||||
const existing = t.players?.find(p => p.userId === userId);
|
||||
if (existing) return true; // already joined
|
||||
try {
|
||||
await db.query(
|
||||
`INSERT INTO "tournament_player"(tournament_id, user_id, user_name) VALUES($1,$2,$3)`,
|
||||
[t.id, userId, userName]
|
||||
);
|
||||
return true;
|
||||
} catch { return null; }
|
||||
};
|
||||
|
||||
export const startTournament = async (tournamentId: number): Promise<TournamentRound[][] | null> => {
|
||||
const playersRes = await db.query(
|
||||
`SELECT * FROM "tournament_player" WHERE tournament_id=$1`,
|
||||
[tournamentId]
|
||||
);
|
||||
const players: TournamentPlayer[] = playersRes.rows.map(r => ({
|
||||
tournamentId: r.tournament_id, userId: r.user_id, userName: r.user_name, score: 0, gamesPlayed: 0
|
||||
}));
|
||||
if (players.length < 2) return null;
|
||||
|
||||
const pairings = generateRoundRobinPairings(players);
|
||||
const totalRounds = pairings.length;
|
||||
|
||||
const allRounds: TournamentRound[][] = [];
|
||||
for (let r = 0; r < pairings.length; r++) {
|
||||
const roundRows: TournamentRound[] = [];
|
||||
for (const game of pairings[r]) {
|
||||
const res = await db.query(
|
||||
`INSERT INTO "tournament_round"(tournament_id, round, white_id, white_name, black_id, black_name) VALUES($1,$2,$3,$4,$5,$6) RETURNING *`,
|
||||
[tournamentId, r + 1, game.white.userId, game.white.userName, game.black.userId, game.black.userName]
|
||||
);
|
||||
roundRows.push(res.rows[0]);
|
||||
}
|
||||
allRounds.push(roundRows);
|
||||
}
|
||||
|
||||
await db.query(
|
||||
`UPDATE "tournament" SET status='active', current_round=1, total_rounds=$1 WHERE id=$2`,
|
||||
[totalRounds, tournamentId]
|
||||
);
|
||||
return allRounds;
|
||||
};
|
||||
|
||||
export const findByCode = async (code: string): Promise<Tournament | null> => {
|
||||
const res = await db.query(`SELECT * FROM "tournament" WHERE code=$1`, [code]);
|
||||
if (!res.rows[0]) return null;
|
||||
const t = res.rows[0];
|
||||
const playersRes = await db.query(`SELECT * FROM "tournament_player" WHERE tournament_id=$1 ORDER BY score DESC, games_played`, [t.id]);
|
||||
const roundsRes = await db.query(`SELECT * FROM "tournament_round" WHERE tournament_id=$1 ORDER BY round, id`, [t.id]);
|
||||
return mapTournament(t, playersRes.rows, roundsRes.rows);
|
||||
};
|
||||
|
||||
export const findAll = async (): Promise<Tournament[]> => {
|
||||
const res = await db.query(`SELECT * FROM "tournament" WHERE status != 'finished' ORDER BY created_at DESC`);
|
||||
return res.rows.map(r => mapTournament(r, [], []));
|
||||
};
|
||||
|
||||
export const setRoundGameCode = async (roundId: number, gameCode: string): Promise<void> => {
|
||||
await db.query(`UPDATE "tournament_round" SET game_code=$1 WHERE id=$2`, [gameCode, roundId]);
|
||||
};
|
||||
|
||||
export const updateRoundResult = async (gameCode: string, result: "white" | "black" | "draw"): Promise<void> => {
|
||||
const roundRes = await db.query(
|
||||
`UPDATE "tournament_round" SET result=$1 WHERE game_code=$2 RETURNING *`,
|
||||
[result, gameCode]
|
||||
);
|
||||
if (!roundRes.rows[0]) return;
|
||||
const round = roundRes.rows[0];
|
||||
|
||||
// Update scores
|
||||
if (result === "white") {
|
||||
await db.query(`UPDATE "tournament_player" SET score=score+1, games_played=games_played+1 WHERE tournament_id=$1 AND user_id=$2`, [round.tournament_id, round.white_id]);
|
||||
await db.query(`UPDATE "tournament_player" SET games_played=games_played+1 WHERE tournament_id=$1 AND user_id=$2`, [round.tournament_id, round.black_id]);
|
||||
} else if (result === "black") {
|
||||
await db.query(`UPDATE "tournament_player" SET score=score+1, games_played=games_played+1 WHERE tournament_id=$1 AND user_id=$2`, [round.tournament_id, round.black_id]);
|
||||
await db.query(`UPDATE "tournament_player" SET games_played=games_played+1 WHERE tournament_id=$1 AND user_id=$2`, [round.tournament_id, round.white_id]);
|
||||
} else {
|
||||
await db.query(`UPDATE "tournament_player" SET score=score+0.5, games_played=games_played+1 WHERE tournament_id=$1 AND (user_id=$2 OR user_id=$3)`, [round.tournament_id, round.white_id, round.black_id]);
|
||||
}
|
||||
|
||||
// Check if all games in this round are done -> advance round or finish tournament
|
||||
const pendingRes = await db.query(
|
||||
`SELECT COUNT(*) FROM "tournament_round" WHERE tournament_id=$1 AND round=$2 AND result IS NULL`,
|
||||
[round.tournament_id, round.round]
|
||||
);
|
||||
if (parseInt(pendingRes.rows[0].count) === 0) {
|
||||
const nextRes = await db.query(
|
||||
`SELECT COUNT(*) FROM "tournament_round" WHERE tournament_id=$1 AND round=$2`,
|
||||
[round.tournament_id, round.round + 1]
|
||||
);
|
||||
if (parseInt(nextRes.rows[0].count) > 0) {
|
||||
await db.query(`UPDATE "tournament" SET current_round=current_round+1 WHERE id=$1`, [round.tournament_id]);
|
||||
} else {
|
||||
await db.query(`UPDATE "tournament" SET status='finished' WHERE id=$1`, [round.tournament_id]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const TournamentModel = { createTournament, joinTournament, startTournament, findByCode, findAll, setRoundGameCode, updateRoundResult };
|
||||
export default TournamentModel;
|
||||
14
server/src/routes/correspondence.route.ts
Normal file
14
server/src/routes/correspondence.route.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import express from "express";
|
||||
import {
|
||||
createCorrespondenceGame, getMyCorrespondenceGames, getCorrespondenceGame,
|
||||
joinCorrespondenceGame, makeCorrespondenceMove, resignCorrespondenceGame
|
||||
} from "../controllers/correspondence.controller.js";
|
||||
|
||||
const router = express.Router();
|
||||
router.post("/", createCorrespondenceGame);
|
||||
router.get("/", getMyCorrespondenceGames);
|
||||
router.get("/:code", getCorrespondenceGame);
|
||||
router.post("/:code/join", joinCorrespondenceGame);
|
||||
router.post("/:code/move", makeCorrespondenceMove);
|
||||
router.post("/:code/resign", resignCorrespondenceGame);
|
||||
export default router;
|
||||
@@ -3,8 +3,10 @@ import { Router } from "express";
|
||||
import admin from "./admin.route.js";
|
||||
import ai from "./ai.route.js";
|
||||
import auth from "./auth.route.js";
|
||||
import correspondence from "./correspondence.route.js";
|
||||
import friends from "./friends.route.js";
|
||||
import games from "./games.route.js";
|
||||
import tournaments from "./tournament.route.js";
|
||||
import users from "./users.route.js";
|
||||
|
||||
const router = Router();
|
||||
@@ -15,5 +17,7 @@ router.use("/users", users);
|
||||
router.use("/admin", admin);
|
||||
router.use("/friends", friends);
|
||||
router.use("/ai", ai);
|
||||
router.use("/correspondence", correspondence);
|
||||
router.use("/tournaments", tournaments);
|
||||
|
||||
export default router;
|
||||
|
||||
10
server/src/routes/tournament.route.ts
Normal file
10
server/src/routes/tournament.route.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import express from "express";
|
||||
import { createTournament, getTournaments, getTournament, joinTournament, startTournament } from "../controllers/tournament.controller.js";
|
||||
|
||||
const router = express.Router();
|
||||
router.post("/", createTournament);
|
||||
router.get("/", getTournaments);
|
||||
router.get("/:code", getTournament);
|
||||
router.post("/:code/join", joinTournament);
|
||||
router.post("/:code/start", startTournament);
|
||||
export default router;
|
||||
@@ -3,6 +3,7 @@ import { Chess } from "chess.js";
|
||||
import type { DisconnectReason, Socket } from "socket.io";
|
||||
|
||||
import GameModel, { activeGames } from "../db/models/game.model.js";
|
||||
import TournamentModel from "../db/models/tournament.model.js";
|
||||
import { io } from "../server.js";
|
||||
|
||||
// TODO: clean up
|
||||
@@ -49,6 +50,16 @@ export async function joinLobby(this: Socket, gameCode: string) {
|
||||
|
||||
await this.join(gameCode);
|
||||
io.to(game.code as string).emit("receivedLatestGame", game);
|
||||
|
||||
// Start clock if both players are connected and game has timeControl but hasn't started timing yet
|
||||
if (game.timeControl && game.white?.connected && game.black?.connected && !game.lastMoveAt && !game.pgn) {
|
||||
game.lastMoveAt = Date.now();
|
||||
io.to(game.code as string).emit("clockUpdate", {
|
||||
whiteTimeMs: game.whiteTimeMs,
|
||||
blackTimeMs: game.blackTimeMs,
|
||||
lastMoveAt: game.lastMoveAt
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function leaveLobby(this: Socket, reason?: DisconnectReason, code?: string) {
|
||||
@@ -141,6 +152,8 @@ export async function claimAbandoned(this: Socket, type: "win" | "draw") {
|
||||
const { id } = (await GameModel.save(game)) as Game;
|
||||
game.id = id;
|
||||
|
||||
await TournamentModel.updateRoundResult(game.code!, game.winner as "white" | "black" | "draw");
|
||||
|
||||
const gameOver = {
|
||||
reason: game.endReason,
|
||||
winnerName: this.request.session.user.name,
|
||||
@@ -178,11 +191,48 @@ export async function sendMove(this: Socket, m: { from: string; to: string; prom
|
||||
throw new Error("not turn to move");
|
||||
}
|
||||
|
||||
// Time tracking
|
||||
let timeExpired = false;
|
||||
if (game.timeControl && game.lastMoveAt && game.whiteTimeMs !== undefined && game.blackTimeMs !== undefined) {
|
||||
const elapsed = Date.now() - game.lastMoveAt;
|
||||
if (prevTurn === "w") {
|
||||
game.whiteTimeMs = Math.max(0, game.whiteTimeMs - elapsed);
|
||||
if (game.whiteTimeMs <= 0) timeExpired = true;
|
||||
} else {
|
||||
game.blackTimeMs = Math.max(0, game.blackTimeMs - elapsed);
|
||||
if (game.blackTimeMs <= 0) timeExpired = true;
|
||||
}
|
||||
game.lastMoveAt = Date.now();
|
||||
}
|
||||
|
||||
if (timeExpired) {
|
||||
game.winner = prevTurn === "w" ? "black" : "white";
|
||||
game.endReason = "timeout";
|
||||
const saved = (await GameModel.save(game)) as Game;
|
||||
const id = saved?.id;
|
||||
game.id = id;
|
||||
await TournamentModel.updateRoundResult(game.code!, game.winner as "white" | "black" | "draw");
|
||||
io.to(game.code as string).emit("gameOver", { reason: "timeout", winnerSide: game.winner, id });
|
||||
if (game.timeout) clearTimeout(game.timeout);
|
||||
activeGames.splice(activeGames.indexOf(game), 1);
|
||||
return;
|
||||
}
|
||||
|
||||
const newMove = chess.move(m);
|
||||
|
||||
if (newMove) {
|
||||
game.pgn = chess.pgn();
|
||||
this.to(game.code as string).emit("receivedMove", m);
|
||||
|
||||
// Emit clock update after move
|
||||
if (game.timeControl) {
|
||||
io.to(game.code as string).emit("clockUpdate", {
|
||||
whiteTimeMs: game.whiteTimeMs,
|
||||
blackTimeMs: game.blackTimeMs,
|
||||
lastMoveAt: game.lastMoveAt
|
||||
});
|
||||
}
|
||||
|
||||
if (chess.isGameOver()) {
|
||||
let reason: Game["endReason"];
|
||||
if (chess.isCheckmate()) reason = "checkmate";
|
||||
@@ -208,6 +258,7 @@ export async function sendMove(this: Socket, m: { from: string; to: string; prom
|
||||
|
||||
const { id } = (await GameModel.save(game)) as Game; // save game to db
|
||||
game.id = id;
|
||||
await TournamentModel.updateRoundResult(game.code!, game.winner as "white" | "black" | "draw");
|
||||
io.to(game.code as string).emit("gameOver", { reason, winnerName, winnerSide, id });
|
||||
|
||||
if (game.timeout) clearTimeout(game.timeout);
|
||||
@@ -240,6 +291,14 @@ export async function joinAsPlayer(this: Socket) {
|
||||
side: "white"
|
||||
});
|
||||
game.startedAt = Date.now();
|
||||
if (game.timeControl) {
|
||||
game.lastMoveAt = Date.now();
|
||||
io.to(game.code as string).emit("clockUpdate", {
|
||||
whiteTimeMs: game.whiteTimeMs,
|
||||
blackTimeMs: game.blackTimeMs,
|
||||
lastMoveAt: game.lastMoveAt
|
||||
});
|
||||
}
|
||||
} else if (!game.black) {
|
||||
const sessionUser = {
|
||||
id: this.request.session.user.id,
|
||||
@@ -253,6 +312,14 @@ export async function joinAsPlayer(this: Socket) {
|
||||
side: "black"
|
||||
});
|
||||
game.startedAt = Date.now();
|
||||
if (game.timeControl) {
|
||||
game.lastMoveAt = Date.now();
|
||||
io.to(game.code as string).emit("clockUpdate", {
|
||||
whiteTimeMs: game.whiteTimeMs,
|
||||
blackTimeMs: game.blackTimeMs,
|
||||
lastMoveAt: game.lastMoveAt
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.log("joinAsPlayer: attempted to join a game with already 2 players");
|
||||
}
|
||||
@@ -265,3 +332,29 @@ export async function chat(this: Socket, message: string) {
|
||||
message
|
||||
});
|
||||
}
|
||||
|
||||
export async function claimTimeout(this: Socket) {
|
||||
const game = activeGames.find(g => g.code === Array.from(this.rooms)[1]);
|
||||
if (!game || !game.timeControl || !game.lastMoveAt || game.endReason || game.winner) return;
|
||||
if (!game.white || !game.black) return;
|
||||
|
||||
const chess = new Chess();
|
||||
if (game.pgn) chess.loadPgn(game.pgn);
|
||||
|
||||
const elapsed = Date.now() - game.lastMoveAt;
|
||||
const turn = chess.turn(); // 'w' or 'b'
|
||||
const currentTimeMs = turn === "w" ? (game.whiteTimeMs ?? 0) : (game.blackTimeMs ?? 0);
|
||||
|
||||
if (currentTimeMs - elapsed > 1000) return; // 1s tolerance
|
||||
|
||||
game.winner = turn === "w" ? "black" : "white";
|
||||
game.endReason = "timeout";
|
||||
|
||||
const saved = (await GameModel.save(game)) as Game;
|
||||
const id = saved?.id;
|
||||
game.id = id;
|
||||
await TournamentModel.updateRoundResult(game.code!, game.winner as "white" | "black" | "draw");
|
||||
io.to(game.code as string).emit("gameOver", { reason: "timeout", winnerSide: game.winner, id });
|
||||
if (game.timeout) clearTimeout(game.timeout);
|
||||
activeGames.splice(activeGames.indexOf(game), 1);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { io } from "../server.js";
|
||||
import {
|
||||
chat,
|
||||
claimAbandoned,
|
||||
claimTimeout,
|
||||
getLatestGame,
|
||||
joinAsPlayer,
|
||||
joinLobby,
|
||||
@@ -34,6 +35,7 @@ const socketConnect = (socket: Socket) => {
|
||||
socket.on("joinAsPlayer", joinAsPlayer);
|
||||
socket.on("chat", chat);
|
||||
socket.on("claimAbandoned", claimAbandoned);
|
||||
socket.on("claimTimeout", claimTimeout);
|
||||
};
|
||||
|
||||
export const init = () => {
|
||||
|
||||
57
types/index.d.ts
vendored
57
types/index.d.ts
vendored
@@ -4,7 +4,7 @@ export interface Game {
|
||||
white?: User;
|
||||
black?: User;
|
||||
winner?: "white" | "black" | "draw";
|
||||
endReason?: "draw" | "checkmate" | "stalemate" | "repetition" | "insufficient" | "abandoned";
|
||||
endReason?: "draw" | "checkmate" | "stalemate" | "repetition" | "insufficient" | "abandoned" | "timeout" | "resign";
|
||||
host?: User;
|
||||
code?: string;
|
||||
unlisted?: boolean;
|
||||
@@ -14,6 +14,10 @@ export interface Game {
|
||||
endedAt?: number;
|
||||
vsAi?: boolean;
|
||||
aiLevel?: number;
|
||||
timeControl?: number;
|
||||
whiteTimeMs?: number;
|
||||
blackTimeMs?: number;
|
||||
lastMoveAt?: number;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
@@ -50,3 +54,54 @@ export interface Friendship {
|
||||
draws?: number;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface CorrespondenceGame {
|
||||
id?: number;
|
||||
code?: string;
|
||||
white?: { id?: number; name?: string };
|
||||
black?: { id?: number; name?: string };
|
||||
pgn?: string;
|
||||
winner?: "white" | "black" | "draw";
|
||||
endReason?: "checkmate" | "stalemate" | "repetition" | "insufficient" | "resign" | "timeout" | "draw";
|
||||
daysPerMove?: number;
|
||||
lastMoveAt?: number;
|
||||
startedAt?: number;
|
||||
endedAt?: number;
|
||||
}
|
||||
|
||||
export interface Tournament {
|
||||
id?: number;
|
||||
code?: string;
|
||||
name?: string;
|
||||
hostId?: number;
|
||||
hostName?: string;
|
||||
status?: "waiting" | "active" | "finished";
|
||||
timeControl?: number;
|
||||
maxPlayers?: number;
|
||||
currentRound?: number;
|
||||
totalRounds?: number;
|
||||
players?: TournamentPlayer[];
|
||||
rounds?: TournamentRound[];
|
||||
createdAt?: number;
|
||||
}
|
||||
|
||||
export interface TournamentPlayer {
|
||||
tournamentId?: number;
|
||||
userId?: number;
|
||||
userName?: string;
|
||||
score?: number;
|
||||
gamesPlayed?: number;
|
||||
}
|
||||
|
||||
export interface TournamentRound {
|
||||
id?: number;
|
||||
tournamentId?: number;
|
||||
round?: number;
|
||||
gameCode?: string;
|
||||
gameId?: number;
|
||||
whiteId?: number;
|
||||
whiteName?: string;
|
||||
blackId?: number;
|
||||
blackName?: string;
|
||||
result?: "white" | "black" | "draw" | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user