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