From 86fbdd39ed921975e8c5a96591cb366c40250594 Mon Sep 17 00:00:00 2001 From: Michess Date: Wed, 15 Apr 2026 07:46:40 +0200 Subject: [PATCH] fix: resolve all 3 open Gitea issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #1 - Bot vs Bot Turnierspiele: - triggerBotMove ruft sich rekursiv selbst auf wenn der nächste Spieler auch ein Bot ist (bot vs bot läuft jetzt vollautomatisch durch) - startNextRoundGames erstellt aktive Spiele für die nächste Runde wenn eine Runde endet; bot vs bot Spiele starten sofort ohne Spectator - updateRoundResult gibt jetzt die nächste Runden-Daten zurück statt void - Alle Handler (sendMove, claimAbandoned, claimTimeout, triggerBotMove) starten die nächste Runde automatisch Issue #2 - Bot-Schwierigkeit zu hoch: - randomChance zu StockfishLevel hinzugefügt: Level 1=80%, 2=50%, 3=15%, 4=3%, 5-6=0% zufällige Züge statt Stockfish-Best-Move - Gilt sowohl für Socket-Bot-Züge als auch /v1/ai/move REST-Endpoint Issue #3 - Turnier beenden + Zurück-Button: - POST /:code/cancel Endpoint (nur Host), setzt status='finished' - Turnier-Seite: 'Turnier beenden' Button für Host (rot, outline) - tournamentCode Feld im Game-Typ; wird beim Erstellen von Turnierspielen gesetzt (inkl. Folgerunden) - GamePage zeigt '← Zur Turnierübersicht' Link wenn tournamentCode gesetzt Co-Authored-By: Claude Sonnet 4.6 --- client/src/app/tournament/[code]/page.tsx | 19 +++- client/src/components/game/GamePage.tsx | 9 +- client/src/lib/tournament.ts | 6 + .../src/controllers/stockfish.controller.ts | 13 ++- .../src/controllers/tournament.controller.ts | 16 ++- server/src/db/models/tournament.model.ts | 12 +- server/src/routes/ai.route.ts | 17 ++- server/src/routes/tournament.route.ts | 3 +- server/src/socket/game.socket.ts | 107 +++++++++++++----- types/index.d.ts | 1 + 10 files changed, 159 insertions(+), 44 deletions(-) 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 && ( +
+ + ← Zur Turnierübersicht + +
+ )}
{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 {