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

@@ -0,0 +1,85 @@
import type { Request, Response } from "express";
import CorrespondenceModel from "../db/models/correspondence.model.js";
import { Chess } from "chess.js";
export const createCorrespondenceGame = async (req: Request, res: Response) => {
if (!req.session.user?.id || typeof req.session.user.id === "string") { res.status(401).end(); return; }
try {
const game = await CorrespondenceModel.create(req.session.user.id as number, req.session.user.name!, req.body.daysPerMove || 3);
res.status(201).json(game);
} catch (e) { console.error(e); res.status(500).end(); }
};
export const getMyCorrespondenceGames = async (req: Request, res: Response) => {
if (!req.session.user?.id || typeof req.session.user.id === "string") { res.status(401).end(); return; }
try {
const games = await CorrespondenceModel.findByUserId(req.session.user.id as number);
res.status(200).json(games);
} catch (e) { res.status(500).end(); }
};
export const getCorrespondenceGame = async (req: Request, res: Response) => {
try {
const game = await CorrespondenceModel.findByCode(req.params.code);
if (!game) { res.status(404).end(); return; }
res.status(200).json(game);
} catch (e) { res.status(500).end(); }
};
export const joinCorrespondenceGame = async (req: Request, res: Response) => {
if (!req.session.user?.id || typeof req.session.user.id === "string") { res.status(401).end(); return; }
try {
const game = await CorrespondenceModel.join(req.params.code, req.session.user.id as number, req.session.user.name!);
if (!game) { res.status(400).json({ message: "Spiel nicht verfügbar." }); return; }
res.status(200).json(game);
} catch (e) { res.status(500).end(); }
};
export const makeCorrespondenceMove = async (req: Request, res: Response) => {
if (!req.session.user?.id || typeof req.session.user.id === "string") { res.status(401).end(); return; }
try {
const { from, to, promotion } = req.body;
const game = await CorrespondenceModel.findByCode(req.params.code);
if (!game || game.winner || game.endReason) { res.status(400).json({ message: "Spiel beendet." }); return; }
if (!game.black?.id) { res.status(400).json({ message: "Kein Gegner." }); return; }
const chess = new Chess();
if (game.pgn) chess.loadPgn(game.pgn);
const turn = chess.turn();
const userId = req.session.user.id as number;
const isWhite = game.white?.id === userId;
const isBlack = game.black?.id === userId;
if (!isWhite && !isBlack) { res.status(403).end(); return; }
if ((turn === "w" && !isWhite) || (turn === "b" && !isBlack)) {
res.status(403).json({ message: "Nicht dein Zug." }); return;
}
const move = chess.move({ from, to, promotion: promotion || "q" });
if (!move) { res.status(400).json({ message: "Ungültiger Zug." }); return; }
let winner: string | undefined, endReason: string | undefined;
if (chess.isGameOver()) {
if (chess.isCheckmate()) { endReason = "checkmate"; winner = turn === "w" ? "white" : "black"; }
else if (chess.isStalemate()) { endReason = "stalemate"; winner = "draw"; }
else if (chess.isThreefoldRepetition()) { endReason = "repetition"; winner = "draw"; }
else if (chess.isInsufficientMaterial()) { endReason = "insufficient"; winner = "draw"; }
else { endReason = "draw"; winner = "draw"; }
}
const updated = await CorrespondenceModel.applyMove(req.params.code, chess.pgn(), winner, endReason);
res.status(200).json(updated);
} catch (e) { console.error(e); res.status(500).end(); }
};
export const resignCorrespondenceGame = async (req: Request, res: Response) => {
if (!req.session.user?.id || typeof req.session.user.id === "string") { res.status(401).end(); return; }
try {
const game = await CorrespondenceModel.findByCode(req.params.code);
if (!game || game.endReason) { res.status(400).end(); return; }
const userId = req.session.user.id as number;
if (game.white?.id !== userId && game.black?.id !== userId) { res.status(403).end(); return; }
const updated = await CorrespondenceModel.resign(req.params.code, userId);
res.status(200).json(updated);
} catch (e) { res.status(500).end(); }
};

View File

@@ -78,11 +78,15 @@ export const createGame = async (req: Request, res: Response) => {
connected: false
};
const unlisted: boolean = req.body.unlisted ?? false;
const timeControl: number | undefined = req.body.timeControl ? parseInt(req.body.timeControl) : undefined;
const game: Game = {
code: nanoid(6),
unlisted,
host: user,
pgn: ""
pgn: "",
timeControl: timeControl || undefined,
whiteTimeMs: timeControl ? timeControl * 60 * 1000 : undefined,
blackTimeMs: timeControl ? timeControl * 60 * 1000 : undefined
};
if (req.body.side === "white") {
game.white = user;

View File

@@ -0,0 +1,82 @@
import type { Request, Response } from "express";
import TournamentModel from "../db/models/tournament.model.js";
import { activeGames } from "../db/models/game.model.js";
import { nanoid } from "nanoid";
import type { Game } from "@michess/types";
export const createTournament = async (req: Request, res: Response) => {
if (!req.session.user?.id || typeof req.session.user.id === "string") { res.status(401).end(); return; }
const { name, timeControl, maxPlayers } = req.body;
if (!name) { res.status(400).json({ message: "Name erforderlich." }); return; }
try {
const tournament = await TournamentModel.createTournament(
req.session.user.id as number, req.session.user.name!, name,
timeControl ? parseInt(timeControl) : undefined, maxPlayers ? parseInt(maxPlayers) : 8
);
await TournamentModel.joinTournament(tournament.code!, req.session.user.id as number, req.session.user.name!);
const updated = await TournamentModel.findByCode(tournament.code!);
res.status(201).json(updated);
} catch (e) { console.error(e); res.status(500).end(); }
};
export const getTournaments = async (_req: Request, res: Response) => {
try {
const tournaments = await TournamentModel.findAll();
res.status(200).json(tournaments);
} catch (e) { res.status(500).end(); }
};
export const getTournament = async (req: Request, res: Response) => {
try {
const tournament = await TournamentModel.findByCode(req.params.code);
if (!tournament) { res.status(404).end(); return; }
res.status(200).json(tournament);
} catch (e) { res.status(500).end(); }
};
export const joinTournament = async (req: Request, res: Response) => {
if (!req.session.user?.id || typeof req.session.user.id === "string") { res.status(401).end(); return; }
try {
const result = await TournamentModel.joinTournament(req.params.code, req.session.user.id as number, req.session.user.name!);
if (!result) { res.status(400).json({ message: "Turnier nicht verfügbar." }); return; }
const updated = await TournamentModel.findByCode(req.params.code);
res.status(200).json(updated);
} catch (e) { res.status(500).end(); }
};
export const startTournament = async (req: Request, res: Response) => {
if (!req.session.user?.id || typeof req.session.user.id === "string") { res.status(401).end(); return; }
try {
const tournament = await TournamentModel.findByCode(req.params.code);
if (!tournament) { res.status(404).end(); return; }
if (tournament.hostId !== req.session.user.id) { res.status(403).end(); return; }
if (tournament.status !== "waiting") { res.status(400).json({ message: "Turnier bereits gestartet." }); return; }
if (!tournament.players || tournament.players.length < 2) { res.status(400).json({ message: "Mindestens 2 Spieler erforderlich." }); return; }
const allRounds = await TournamentModel.startTournament(tournament.id!);
if (!allRounds) { res.status(500).end(); return; }
// Create active games for round 1
const round1 = allRounds[0];
for (const roundRow of round1) {
const code = nanoid(6);
const tc = tournament.timeControl;
const row = roundRow as any;
const whiteUser = { id: row.white_id, name: row.white_name, connected: false };
const blackUser = { id: row.black_id, name: row.black_name, connected: false };
const game: Game = {
code, unlisted: true, host: whiteUser,
white: whiteUser, black: blackUser, pgn: "",
startedAt: Date.now(),
timeControl: tc || undefined,
whiteTimeMs: tc ? tc * 60 * 1000 : undefined,
blackTimeMs: tc ? tc * 60 * 1000 : undefined
};
activeGames.push(game);
await TournamentModel.setRoundGameCode(row.id, code);
}
const updated = await TournamentModel.findByCode(req.params.code);
res.status(200).json(updated);
} catch (e) { console.error(e); res.status(500).end(); }
};