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:
Michess
2026-04-14 09:44:35 +02:00
parent 09e5271dfb
commit aeb5fe313e
25 changed files with 1653 additions and 16 deletions

View File

@@ -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:"}

View File

@@ -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") {

View File

@@ -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"