From aeb5fe313e5f9f9c2e179ced8c546089498815da Mon Sep 17 00:00:00 2001 From: Michess Date: Tue, 14 Apr 2026 09:44:35 +0200 Subject: [PATCH] feat: add time controls, correspondence games, and tournament mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **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 --- client/src/app/correspondence/[code]/page.tsx | 249 ++++++++++++++++++ client/src/app/correspondence/page.tsx | 118 +++++++++ client/src/app/page.tsx | 30 ++- client/src/app/tournament/[code]/page.tsx | 211 +++++++++++++++ client/src/app/tournament/create/page.tsx | 107 ++++++++ client/src/app/tournament/page.tsx | 97 +++++++ client/src/components/game/GamePage.tsx | 67 ++++- client/src/components/game/socketEvents.ts | 15 ++ client/src/components/home/CreateGame.tsx | 17 +- client/src/lib/correspondence.ts | 46 ++++ client/src/lib/game.ts | 4 +- client/src/lib/tournament.ts | 36 +++ .../controllers/correspondence.controller.ts | 85 ++++++ server/src/controllers/games.controller.ts | 6 +- .../src/controllers/tournament.controller.ts | 82 ++++++ server/src/db/index.ts | 53 ++++ server/src/db/models/correspondence.model.ts | 79 ++++++ server/src/db/models/game.model.ts | 8 +- server/src/db/models/tournament.model.ts | 179 +++++++++++++ server/src/routes/correspondence.route.ts | 14 + server/src/routes/index.ts | 4 + server/src/routes/tournament.route.ts | 10 + server/src/socket/game.socket.ts | 93 +++++++ server/src/socket/index.ts | 2 + types/index.d.ts | 57 +++- 25 files changed, 1653 insertions(+), 16 deletions(-) create mode 100644 client/src/app/correspondence/[code]/page.tsx create mode 100644 client/src/app/correspondence/page.tsx create mode 100644 client/src/app/tournament/[code]/page.tsx create mode 100644 client/src/app/tournament/create/page.tsx create mode 100644 client/src/app/tournament/page.tsx create mode 100644 client/src/lib/correspondence.ts create mode 100644 client/src/lib/tournament.ts create mode 100644 server/src/controllers/correspondence.controller.ts create mode 100644 server/src/controllers/tournament.controller.ts create mode 100644 server/src/db/models/correspondence.model.ts create mode 100644 server/src/db/models/tournament.model.ts create mode 100644 server/src/routes/correspondence.route.ts create mode 100644 server/src/routes/tournament.route.ts diff --git a/client/src/app/correspondence/[code]/page.tsx b/client/src/app/correspondence/[code]/page.tsx new file mode 100644 index 0000000..2cf3389 --- /dev/null +++ b/client/src/app/correspondence/[code]/page.tsx @@ -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(null); + const [chess] = useState(() => new Chess()); + const [fen, setFen] = useState("start"); + const [loading, setLoading] = useState(true); + const [moveFrom, setMoveFrom] = useState(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 ( +
+ +
+ ); + } + + if (!game) { + return
Partie nicht gefunden.
; + } + + const boardOrientation = isBlack ? "black" : "white"; + const winnerName = game.winner === "white" ? game.white?.name : game.winner === "black" ? game.black?.name : null; + + return ( +
+
+ + 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)" } } : {}} + /> +
+ +
+ {/* Players */} +
+
+
+ {game.white?.name ?? "?"} + Weiß +
+
vs
+
+ {game.black?.name ?? "Wartet auf Gegner"} + Schwarz +
+
+
+ + {/* Status / winner banner */} + {game.winner ? ( +
+ + {game.winner === "draw" + ? "Remis!" + : `${winnerName} gewinnt (${game.endReason === "checkmate" ? "Matt" : game.endReason === "resign" ? "Aufgabe" : game.endReason})!`} + +
+ ) : !game.black?.id ? ( +
+
+

Warte auf Gegner. Einladungslink:

+ + {!isPlayer && user?.id && ( + + )} +
+
+ ) : ( +
+ + {myTurn ? "Du bist dran!" : `${chess.turn() === "w" ? game.white?.name : game.black?.name} ist dran.`} + +
+ )} + + {/* Move list */} +
+
+

Züge

+
+ + + {getMoveList().map((pair, i) => ( + + + + + + ))} + +
{i + 1}.{pair.w}{pair.b ?? ""}
+
+
+
+ + {/* Resign button */} + {isPlayer && !game.winner && game.black?.id && ( + + )} +
+
+ ); +} diff --git a/client/src/app/correspondence/page.tsx b/client/src/app/correspondence/page.tsx new file mode 100644 index 0000000..3babb9f --- /dev/null +++ b/client/src/app/correspondence/page.tsx @@ -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([]); + 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 ( +
+

Bitte einloggen um Tagespartien zu spielen.

+ Einloggen +
+ ); + } + + return ( +
+
+

Tagespartien

+
+ +
+
+

Neue Partie erstellen

+
+ + + +
+
+
+ + {loading ? ( +
+ +
+ ) : games.length === 0 ? ( +
Keine aktiven Tagespartien.
+ ) : ( +
+ {games.map((game) => ( +
+
+
+
+ {game.white?.name ?? "?"} vs {game.black?.name ?? "Warte auf Gegner"} +
+
{getTurnLabel(game)}
+
+ + Spielen + +
+
+ ))} +
+ )} +
+ ); +} diff --git a/client/src/app/page.tsx b/client/src/app/page.tsx index 8ab2d09..cd1ddb7 100644 --- a/client/src/app/page.tsx +++ b/client/src/app/page.tsx @@ -14,11 +14,11 @@ export default function Home() {
{/* Hauptaktionen */} -
+
-

♟ Spiel erstellen

+

Spiel erstellen

Erstelle eine Partie und teile den Einladungslink mit Freunden

@@ -28,7 +28,7 @@ export default function Home() {
-

🤖 Gegen KI spielen

+

Gegen KI spielen

Fordere Stockfish auf verschiedenen Stufen heraus — von Anfänger bis Meister

Jetzt spielen @@ -40,7 +40,7 @@ export default function Home() {
{isLoggedIn ? ( <> -

👥 Freunde

+

Freunde

Freunde hinzufügen, verwalten und zu einer Partie einladen

Freunde verwalten @@ -48,7 +48,7 @@ export default function Home() { ) : ( <> -

🔗 Einladung annehmen

+

Einladung annehmen

Einladungslink oder Code eingeben um beizutreten

@@ -56,6 +56,26 @@ export default function Home() {
+
+
+

Tagespartien

+

Spiele in deinem eigenen Tempo — Züge bis zu 3 Tage Zeit

+
+ Tagespartien +
+
+
+ +
+
+

Turnier

+

Spiele Round-Robin Turniere gegen mehrere Gegner

+
+ Turniere +
+
+
+
{/* Öffentliche Spiele + Beitreten */} diff --git a/client/src/app/tournament/[code]/page.tsx b/client/src/app/tournament/[code]/page.tsx new file mode 100644 index 0000000..78efd47 --- /dev/null +++ b/client/src/app/tournament/[code]/page.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [actionLoading, setActionLoading] = useState(false); + const intervalRef = useRef | 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 Wartet auf Spieler; + if (status === "active") return Aktiv; + return Beendet; + } + + function resultLabel(r: TournamentRound) { + if (!r.result) return ; + if (r.result === "draw") return Remis; + return ( + + {r.result === "white" ? r.whiteName : r.blackName} + + ); + } + + if (loading) { + return ( +
+ +
+ ); + } + + if (!tournament) { + return
Turnier nicht gefunden.
; + } + + 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 = {}; + for (const r of tournament.rounds ?? []) { + if (!rounds[r.round!]) rounds[r.round!] = []; + rounds[r.round!].push(r); + } + + return ( +
+ {/* Header */} +
+
+

{tournament.name}

+
+ {statusBadge(tournament.status)} + {tournament.timeControl + ? {tournament.timeControl} Min/Spieler + : Keine Uhr} + {tournament.status === "active" && ( + Runde {tournament.currentRound}/{tournament.totalRounds} + )} +
+
+
+ {tournament.status === "waiting" && user?.id && !isJoined && ( + + )} + {canStart && ( + + )} +
+
+ + {/* Standings */} +
+
+

Tabelle

+ {!tournament.players?.length ? ( +

Noch keine Spieler.

+ ) : ( + + + + + + + + + + + {tournament.players.map((p, i) => ( + + + + + + + ))} + +
#SpielerPunktePartien
{i + 1}{p.userName}{p.score}{p.gamesPlayed}
+ )} +
+
+ + {/* Rounds */} + {Object.keys(rounds).length > 0 && ( +
+ {Object.entries(rounds).map(([roundNum, games]) => ( +
+
+

+ Runde {roundNum} + {parseInt(roundNum) === tournament.currentRound && tournament.status === "active" && ( + Aktuell + )} +

+
+ {games.map((g) => { + const isMyGame = g.whiteId === user?.id || g.blackId === user?.id; + return ( +
+ + {g.whiteName} + vs + {g.blackName} + +
+ {resultLabel(g)} + {isMyGame && g.gameCode && !g.result && ( + + Spielen + + )} + {g.gameCode && g.result && ( + + Ansehen + + )} +
+
+ ); + })} +
+
+
+ ))} +
+ )} +
+ ); +} diff --git a/client/src/app/tournament/create/page.tsx b/client/src/app/tournament/create/page.tsx new file mode 100644 index 0000000..ee4c6ae --- /dev/null +++ b/client/src/app/tournament/create/page.tsx @@ -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(null); + + async function handleSubmit(e: FormEvent) { + 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 ( +
+

Bitte einloggen um ein Turnier zu erstellen.

+
+ ); + } + + return ( +
+

Turnier erstellen

+ +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ + {error &&
{error}
} + + +
+
+
+ ); +} diff --git a/client/src/app/tournament/page.tsx b/client/src/app/tournament/page.tsx new file mode 100644 index 0000000..be7241e --- /dev/null +++ b/client/src/app/tournament/page.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [joiningCode, setJoiningCode] = useState(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 Wartet; + if (status === "active") return Aktiv; + return Beendet; + } + + return ( +
+
+

Turniere

+ {user?.id && ( + + Turnier erstellen + + )} +
+ + {loading ? ( +
+ +
+ ) : tournaments.length === 0 ? ( +
Keine aktiven Turniere.
+ ) : ( +
+ {tournaments.map((t) => { + const isJoined = t.players?.some((p) => p.userId === user?.id); + return ( +
+
+
+
+
+ {t.name} + {statusBadge(t.status)} +
+
+ {t.players?.length ?? 0}/{t.maxPlayers} Spieler + {t.timeControl ? ` · ${t.timeControl} Min` : " · Keine Uhr"} + {t.status === "active" ? ` · Runde ${t.currentRound}/${t.totalRounds}` : ""} +
+
+
+ {t.status === "waiting" && user?.id && !isJoined && ( + + )} + + Details + +
+
+
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/client/src/components/game/GamePage.tsx b/client/src/components/game/GamePage.tsx index b97a867..f794a76 100644 --- a/client/src/components/game/GamePage.tsx +++ b/client/src/components/game/GamePage.tsx @@ -54,6 +54,11 @@ export default function GamePage({ initialLobby }: { initialLobby: Game }) { const [navFen, setNavFen] = useState(null); const [navIndex, setNavIndex] = useState(null); + const [clockWhite, setClockWhite] = useState(null); + const [clockBlack, setClockBlack] = useState(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([ @@ -65,6 +70,30 @@ export default function GamePage({ initialLobby }: { initialLobby: Game }) { const chatListRef = useRef(null); const moveListRef = useRef(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")}
+ {initialLobby.timeControl && ( +
+ {/* Top clock = opponent */} +
+ {lobby.side === "b" ? formatClock(clockWhite) : formatClock(clockBlack)} +
+ {/* Bottom clock = self */} +
+ {lobby.side === "b" ? formatClock(clockBlack) : formatClock(clockWhite)} +
+
+ )} +
{lobby.endReason ? "Archived link:" : "Invite friends:"} diff --git a/client/src/components/game/socketEvents.ts b/client/src/components/game/socketEvents.ts index 3d234df..71ee658 100644 --- a/client/src/components/game/socketEvents.ts +++ b/client/src/components/game/socketEvents.ts @@ -16,6 +16,7 @@ export function initSocket( makeMove: Function; setNavFen: Dispatch>; setNavIndex: Dispatch>; + 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") { diff --git a/client/src/components/home/CreateGame.tsx b/client/src/components/home/CreateGame.tsx index 547c1aa..7c591fe 100644 --- a/client/src/components/home/CreateGame.tsx +++ b/client/src/components/home/CreateGame.tsx @@ -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() { Nur per Einladung +