diff --git a/client/src/app/tournament/[code]/page.tsx b/client/src/app/tournament/[code]/page.tsx
index 9a99c99..ac133d8 100644
--- a/client/src/app/tournament/[code]/page.tsx
+++ b/client/src/app/tournament/[code]/page.tsx
@@ -2,7 +2,7 @@
import InviteFriendsModal from "@/components/InviteFriendsModal";
import { useSession } from "@/context/session";
-import { fetchTournament, joinTournament, startTournament, addBotToTournament } from "@/lib/tournament";
+import { fetchTournament, joinTournament, startTournament, cancelTournament, addBotToTournament } from "@/lib/tournament";
import { BOTS } from "@/bots";
import type { Tournament, TournamentRound } from "@michess/types";
import Link from "next/link";
@@ -56,6 +56,14 @@ export default function TournamentDetailPage() {
setActionLoading(false);
}
+ async function handleCancel() {
+ if (!user?.id) return;
+ setActionLoading(true);
+ const updated = await cancelTournament(code);
+ if (updated) setTournament(updated);
+ setActionLoading(false);
+ }
+
async function handleStart() {
if (!user?.id) return;
setActionLoading(true);
@@ -162,6 +170,15 @@ export default function TournamentDetailPage() {
Turnier starten
)}
+ {isHost && tournament.status !== "finished" && (
+
+ )}
diff --git a/client/src/components/game/GamePage.tsx b/client/src/components/game/GamePage.tsx
index 5439261..65c24d3 100644
--- a/client/src/components/game/GamePage.tsx
+++ b/client/src/components/game/GamePage.tsx
@@ -656,10 +656,17 @@ export default function GamePage({ initialLobby }: { initialLobby: Game }) {
{!lobby.endReason && (
-
+
)}
+ {initialLobby.tournamentCode && (
+
+ )}
{getMoveListHtml()}
diff --git a/client/src/lib/tournament.ts b/client/src/lib/tournament.ts
index 7a461f8..c32003a 100644
--- a/client/src/lib/tournament.ts
+++ b/client/src/lib/tournament.ts
@@ -35,6 +35,12 @@ export const startTournament = async (code: string): Promise
return res.json();
};
+export const cancelTournament = async (code: string): Promise => {
+ const res = await fetch(`${API_URL}/v1/tournaments/${code}/cancel`, { method: "POST", credentials: "include" });
+ if (!res.ok) return null;
+ return res.json();
+};
+
export const addBotToTournament = async (code: string, botName: string): Promise => {
const res = await fetch(`${API_URL}/v1/tournaments/${code}/add-bot`, {
method: "POST", credentials: "include",
diff --git a/server/src/controllers/stockfish.controller.ts b/server/src/controllers/stockfish.controller.ts
index 77b610c..1912a25 100644
--- a/server/src/controllers/stockfish.controller.ts
+++ b/server/src/controllers/stockfish.controller.ts
@@ -7,15 +7,16 @@ export interface StockfishLevel {
skillLevel: number;
depth: number;
moveTime: number;
+ randomChance: number; // 0–1 probability of playing a random legal move instead of Stockfish's best
}
export const AI_LEVELS: StockfishLevel[] = [
- { level: 1, name: "Anfänger", skillLevel: 0, depth: 1, moveTime: 100 },
- { level: 2, name: "Leicht", skillLevel: 3, depth: 3, moveTime: 200 },
- { level: 3, name: "Mittel", skillLevel: 8, depth: 5, moveTime: 500 },
- { level: 4, name: "Fortgeschritten", skillLevel: 14, depth: 10, moveTime: 1000 },
- { level: 5, name: "Experte", skillLevel: 18, depth: 15, moveTime: 2000 },
- { level: 6, name: "Meister", skillLevel: 20, depth: 20, moveTime: 3000 },
+ { level: 1, name: "Anfänger", skillLevel: 0, depth: 1, moveTime: 50, randomChance: 0.80 },
+ { level: 2, name: "Leicht", skillLevel: 2, depth: 2, moveTime: 100, randomChance: 0.50 },
+ { level: 3, name: "Mittel", skillLevel: 7, depth: 4, moveTime: 300, randomChance: 0.15 },
+ { level: 4, name: "Fortgeschritten", skillLevel: 13, depth: 8, moveTime: 800, randomChance: 0.03 },
+ { level: 5, name: "Experte", skillLevel: 18, depth: 14, moveTime: 1500, randomChance: 0 },
+ { level: 6, name: "Meister", skillLevel: 20, depth: 20, moveTime: 3000, randomChance: 0 },
];
export class StockfishEngine {
diff --git a/server/src/controllers/tournament.controller.ts b/server/src/controllers/tournament.controller.ts
index 9e455bc..47a769b 100644
--- a/server/src/controllers/tournament.controller.ts
+++ b/server/src/controllers/tournament.controller.ts
@@ -71,7 +71,8 @@ export const startTournament = async (req: Request, res: Response) => {
startedAt: Date.now(),
timeControl: tc || undefined,
whiteTimeMs: tc ? tc * 60 * 1000 : undefined,
- blackTimeMs: tc ? tc * 60 * 1000 : undefined
+ blackTimeMs: tc ? tc * 60 * 1000 : undefined,
+ tournamentCode: req.params.code
};
activeGames.push(game);
await TournamentModel.setRoundGameCode(row.id, code);
@@ -82,6 +83,19 @@ export const startTournament = async (req: Request, res: Response) => {
} catch (e) { console.error(e); res.status(500).end(); }
};
+export const cancelTournament = 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 === "finished") { res.status(400).json({ message: "Turnier bereits beendet." }); return; }
+ await db.query(`UPDATE "tournament" SET status='finished' WHERE code=$1`, [req.params.code]);
+ const updated = await TournamentModel.findByCode(req.params.code);
+ res.status(200).json(updated);
+ } catch (e) { console.error(e); res.status(500).end(); }
+};
+
export const addBot = async (req: Request, res: Response) => {
if (!req.session.user?.id || typeof req.session.user.id === "string") { res.status(401).end(); return; }
const { botName } = req.body;
diff --git a/server/src/db/models/tournament.model.ts b/server/src/db/models/tournament.model.ts
index 6b2dd4f..6f5c92f 100644
--- a/server/src/db/models/tournament.model.ts
+++ b/server/src/db/models/tournament.model.ts
@@ -138,12 +138,14 @@ export const setRoundGameCode = async (roundId: number, gameCode: string): Promi
await db.query(`UPDATE "tournament_round" SET game_code=$1 WHERE id=$2`, [gameCode, roundId]);
};
-export const updateRoundResult = async (gameCode: string, result: "white" | "black" | "draw"): Promise => {
+export type NextRoundData = { rows: any[]; timeControl: number | null } | null;
+
+export const updateRoundResult = async (gameCode: string, result: "white" | "black" | "draw"): Promise => {
const roundRes = await db.query(
`UPDATE "tournament_round" SET result=$1 WHERE game_code=$2 RETURNING *`,
[result, gameCode]
);
- if (!roundRes.rows[0]) return;
+ if (!roundRes.rows[0]) return null;
const round = roundRes.rows[0];
// Update scores
@@ -164,15 +166,17 @@ export const updateRoundResult = async (gameCode: string, result: "white" | "bla
);
if (parseInt(pendingRes.rows[0].count) === 0) {
const nextRes = await db.query(
- `SELECT COUNT(*) FROM "tournament_round" WHERE tournament_id=$1 AND round=$2`,
+ `SELECT tr.*, t.time_control, t.code AS tournament_code FROM "tournament_round" tr JOIN "tournament" t ON t.id=tr.tournament_id WHERE tr.tournament_id=$1 AND tr.round=$2`,
[round.tournament_id, round.round + 1]
);
- if (parseInt(nextRes.rows[0].count) > 0) {
+ if (nextRes.rows.length > 0) {
await db.query(`UPDATE "tournament" SET current_round=current_round+1 WHERE id=$1`, [round.tournament_id]);
+ return { rows: nextRes.rows, timeControl: nextRes.rows[0].time_control ?? null };
} else {
await db.query(`UPDATE "tournament" SET status='finished' WHERE id=$1`, [round.tournament_id]);
}
}
+ return null;
};
const TournamentModel = { createTournament, joinTournament, startTournament, findByCode, findAll, setRoundGameCode, updateRoundResult };
diff --git a/server/src/routes/ai.route.ts b/server/src/routes/ai.route.ts
index b043da7..febc58d 100644
--- a/server/src/routes/ai.route.ts
+++ b/server/src/routes/ai.route.ts
@@ -1,5 +1,6 @@
import express from "express";
import type { Request, Response } from "express";
+import { Chess } from "chess.js";
import { AI_LEVELS, getEngine } from "../controllers/stockfish.controller.js";
import { requireAuth } from "../middleware/auth.js";
import GameModel from "../db/models/game.model.js";
@@ -17,10 +18,20 @@ router.post("/move", async (req: Request, res: Response) => {
}
const aiLevel = AI_LEVELS.find((l) => l.level === (level || 3)) || AI_LEVELS[2];
- const engine = getEngine();
- const move = await engine.getBestMove(fen, aiLevel);
- res.status(200).json({ move: move || null });
+ let move: string | null = null;
+ if (aiLevel.randomChance > 0 && Math.random() < aiLevel.randomChance) {
+ const chess = new Chess(fen);
+ const moves = chess.moves({ verbose: true });
+ if (moves.length) {
+ const rand = moves[Math.floor(Math.random() * moves.length)];
+ move = rand.from + rand.to + (rand.promotion ?? "");
+ }
+ } else {
+ move = await getEngine().getBestMove(fen, aiLevel) || null;
+ }
+
+ res.status(200).json({ move });
} catch (err) {
console.error(err);
res.status(500).end();
diff --git a/server/src/routes/tournament.route.ts b/server/src/routes/tournament.route.ts
index abede07..0599c3c 100644
--- a/server/src/routes/tournament.route.ts
+++ b/server/src/routes/tournament.route.ts
@@ -1,5 +1,5 @@
import express from "express";
-import { createTournament, getTournaments, getTournament, joinTournament, startTournament, addBot } from "../controllers/tournament.controller.js";
+import { createTournament, getTournaments, getTournament, joinTournament, startTournament, cancelTournament, addBot } from "../controllers/tournament.controller.js";
const router = express.Router();
router.post("/", createTournament);
@@ -7,5 +7,6 @@ router.get("/", getTournaments);
router.get("/:code", getTournament);
router.post("/:code/join", joinTournament);
router.post("/:code/start", startTournament);
+router.post("/:code/cancel", cancelTournament);
router.post("/:code/add-bot", addBot);
export default router;
diff --git a/server/src/socket/game.socket.ts b/server/src/socket/game.socket.ts
index 215b942..64d0d2c 100644
--- a/server/src/socket/game.socket.ts
+++ b/server/src/socket/game.socket.ts
@@ -1,13 +1,43 @@
import type { Game } from "@michess/types";
import { Chess } from "chess.js";
+import { nanoid } from "nanoid";
import type { DisconnectReason, Socket } from "socket.io";
import GameModel, { activeGames } from "../db/models/game.model.js";
-import TournamentModel from "../db/models/tournament.model.js";
+import TournamentModel, { type NextRoundData } from "../db/models/tournament.model.js";
import { io } from "../server.js";
import { BOTS, BOT_NAMES } from "../bots.js";
import { AI_LEVELS, getEngine } from "../controllers/stockfish.controller.js";
+async function startNextRoundGames(nextRound: NonNullable) {
+ for (const row of nextRound.rows) {
+ const code = nanoid(6);
+ const tc = nextRound.timeControl;
+ 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,
+ tournamentCode: row.tournament_code,
+ };
+ activeGames.push(game);
+ await TournamentModel.setRoundGameCode(row.id, code);
+
+ // Auto-start bot vs bot games immediately
+ if (BOT_NAMES.has(whiteUser.name) && BOT_NAMES.has(blackUser.name)) {
+ game.white!.connected = true;
+ game.black!.connected = true;
+ if (tc) game.lastMoveAt = Date.now();
+ const chess = new Chess();
+ setTimeout(() => triggerBotMove(game, chess), 300);
+ }
+ }
+}
+
// TODO: clean up
async function triggerBotMove(game: Game, chess: Chess) {
@@ -17,8 +47,17 @@ async function triggerBotMove(game: Game, chess: Chess) {
if (!bot || !game.code) return;
const sfLevel = AI_LEVELS.find(l => l.level === bot.level) ?? AI_LEVELS[2];
- const engine = getEngine();
- const bestMove = await engine.getBestMove(chess.fen(), sfLevel);
+
+ // Possibly play a random legal move instead of Stockfish's best (weakens lower levels)
+ let bestMove: string;
+ if (sfLevel.randomChance > 0 && Math.random() < sfLevel.randomChance) {
+ const moves = chess.moves({ verbose: true });
+ if (!moves.length) return;
+ const rand = moves[Math.floor(Math.random() * moves.length)];
+ bestMove = rand.from + rand.to + (rand.promotion ?? "");
+ } else {
+ bestMove = await getEngine().getBestMove(chess.fen(), sfLevel);
+ }
if (!bestMove) return;
// Check game still active
@@ -50,27 +89,37 @@ async function triggerBotMove(game: Game, chess: Chess) {
});
}
- if (chess.isGameOver()) {
- const prevTurn = turn; // the bot was this turn
- let reason: Game["endReason"];
- if (chess.isCheckmate()) reason = "checkmate";
- else if (chess.isStalemate()) reason = "stalemate";
- else if (chess.isThreefoldRepetition()) reason = "repetition";
- else if (chess.isInsufficientMaterial()) reason = "insufficient";
- else reason = "draw";
-
- const winnerSide = reason === "checkmate" ? (prevTurn === "w" ? "white" : "black") : undefined;
- const winnerName = winnerSide === "white" ? game.white?.name : game.black?.name;
- game.winner = reason === "checkmate" ? winnerSide : "draw";
- game.endReason = reason;
-
- const saved = (await GameModel.save(game)) as Game;
- game.id = saved?.id;
- await TournamentModel.updateRoundResult(game.code, game.winner as "white" | "black" | "draw");
- io.to(game.code).emit("gameOver", { reason, winnerName, winnerSide, id: game.id });
- if (game.timeout) clearTimeout(game.timeout);
- activeGames.splice(activeGames.indexOf(game), 1);
+ if (!chess.isGameOver()) {
+ // Bot vs Bot: recursively trigger next bot move
+ const nextPlayer = chess.turn() === "w" ? game.white : game.black;
+ if (BOT_NAMES.has(nextPlayer?.name ?? "")) {
+ setTimeout(() => triggerBotMove(game, chess), 300);
+ }
+ return;
}
+
+ const prevTurn = turn; // the bot was this turn
+ let reason: Game["endReason"];
+ if (chess.isCheckmate()) reason = "checkmate";
+ else if (chess.isStalemate()) reason = "stalemate";
+ else if (chess.isThreefoldRepetition()) reason = "repetition";
+ else if (chess.isInsufficientMaterial()) reason = "insufficient";
+ else reason = "draw";
+
+ const winnerSide = reason === "checkmate" ? (prevTurn === "w" ? "white" : "black") : undefined;
+ const winnerName = winnerSide === "white" ? game.white?.name : game.black?.name;
+ game.winner = reason === "checkmate" ? winnerSide : "draw";
+ game.endReason = reason;
+
+ const saved = (await GameModel.save(game)) as Game;
+ game.id = saved?.id;
+ const nextRound = await TournamentModel.updateRoundResult(game.code, game.winner as "white" | "black" | "draw");
+ io.to(game.code).emit("gameOver", { reason, winnerName, winnerSide, id: game.id });
+ if (game.timeout) clearTimeout(game.timeout);
+ activeGames.splice(activeGames.indexOf(game), 1);
+
+ // Create active games for the next tournament round if one was returned
+ if (nextRound) await startNextRoundGames(nextRound);
}
export async function joinLobby(this: Socket, gameCode: string) {
@@ -232,7 +281,7 @@ export async function claimAbandoned(this: Socket, type: "win" | "draw") {
const { id } = (await GameModel.save(game)) as Game;
game.id = id;
- await TournamentModel.updateRoundResult(game.code!, game.winner as "white" | "black" | "draw");
+ const nextRound1 = await TournamentModel.updateRoundResult(game.code!, game.winner as "white" | "black" | "draw");
const gameOver = {
reason: game.endReason,
@@ -245,6 +294,7 @@ export async function claimAbandoned(this: Socket, type: "win" | "draw") {
if (game.timeout) clearTimeout(game.timeout);
activeGames.splice(activeGames.indexOf(game), 1);
+ if (nextRound1) await startNextRoundGames(nextRound1);
}
// eslint-disable-next-line no-unused-vars
@@ -291,10 +341,11 @@ export async function sendMove(this: Socket, m: { from: string; to: string; prom
const saved = (await GameModel.save(game)) as Game;
const id = saved?.id;
game.id = id;
- await TournamentModel.updateRoundResult(game.code!, game.winner as "white" | "black" | "draw");
+ const nextRoundT = await TournamentModel.updateRoundResult(game.code!, game.winner as "white" | "black" | "draw");
io.to(game.code as string).emit("gameOver", { reason: "timeout", winnerSide: game.winner, id });
if (game.timeout) clearTimeout(game.timeout);
activeGames.splice(activeGames.indexOf(game), 1);
+ if (nextRoundT) await startNextRoundGames(nextRoundT);
return;
}
@@ -345,11 +396,12 @@ export async function sendMove(this: Socket, m: { from: string; to: string; prom
const { id } = (await GameModel.save(game)) as Game; // save game to db
game.id = id;
- await TournamentModel.updateRoundResult(game.code!, game.winner as "white" | "black" | "draw");
+ const nextRoundM = await TournamentModel.updateRoundResult(game.code!, game.winner as "white" | "black" | "draw");
io.to(game.code as string).emit("gameOver", { reason, winnerName, winnerSide, id });
if (game.timeout) clearTimeout(game.timeout);
activeGames.splice(activeGames.indexOf(game), 1);
+ if (nextRoundM) await startNextRoundGames(nextRoundM);
}
} else {
throw new Error("invalid move");
@@ -440,8 +492,9 @@ export async function claimTimeout(this: Socket) {
const saved = (await GameModel.save(game)) as Game;
const id = saved?.id;
game.id = id;
- await TournamentModel.updateRoundResult(game.code!, game.winner as "white" | "black" | "draw");
+ const nextRoundCT = await TournamentModel.updateRoundResult(game.code!, game.winner as "white" | "black" | "draw");
io.to(game.code as string).emit("gameOver", { reason: "timeout", winnerSide: game.winner, id });
if (game.timeout) clearTimeout(game.timeout);
activeGames.splice(activeGames.indexOf(game), 1);
+ if (nextRoundCT) await startNextRoundGames(nextRoundCT);
}
diff --git a/types/index.d.ts b/types/index.d.ts
index abb08a1..bda404c 100644
--- a/types/index.d.ts
+++ b/types/index.d.ts
@@ -18,6 +18,7 @@ export interface Game {
whiteTimeMs?: number;
blackTimeMs?: number;
lastMoveAt?: number;
+ tournamentCode?: string;
}
export interface User {