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