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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user