fix: resolve all 3 open Gitea issues

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 <noreply@anthropic.com>
This commit is contained in:
Michess
2026-04-15 07:46:40 +02:00
parent 536160bba6
commit 86fbdd39ed
10 changed files with 159 additions and 44 deletions

View File

@@ -2,7 +2,7 @@
import InviteFriendsModal from "@/components/InviteFriendsModal"; import InviteFriendsModal from "@/components/InviteFriendsModal";
import { useSession } from "@/context/session"; 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 { BOTS } from "@/bots";
import type { Tournament, TournamentRound } from "@michess/types"; import type { Tournament, TournamentRound } from "@michess/types";
import Link from "next/link"; import Link from "next/link";
@@ -56,6 +56,14 @@ export default function TournamentDetailPage() {
setActionLoading(false); 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() { async function handleStart() {
if (!user?.id) return; if (!user?.id) return;
setActionLoading(true); setActionLoading(true);
@@ -162,6 +170,15 @@ export default function TournamentDetailPage() {
Turnier starten Turnier starten
</button> </button>
)} )}
{isHost && tournament.status !== "finished" && (
<button
className={"btn btn-error btn-sm btn-outline" + (actionLoading ? " loading" : "")}
onClick={handleCancel}
disabled={actionLoading}
>
Turnier beenden
</button>
)}
</div> </div>
</div> </div>

View File

@@ -656,10 +656,17 @@ export default function GamePage({ initialLobby }: { initialLobby: Game }) {
</div> </div>
</div> </div>
{!lobby.endReason && ( {!lobby.endReason && (
<div className="mt-1 flex justify-end"> <div className="mt-1 flex justify-end gap-2">
<InviteFriendsModal gameCode={initialLobby.code!} label="Freunde einladen" /> <InviteFriendsModal gameCode={initialLobby.code!} label="Freunde einladen" />
</div> </div>
)} )}
{initialLobby.tournamentCode && (
<div className="mt-1 flex justify-end">
<a href={`/tournament/${initialLobby.tournamentCode}`} className="btn btn-ghost btn-xs">
Zur Turnierübersicht
</a>
</div>
)}
<div className="h-32 w-full overflow-y-scroll" ref={moveListRef}> <div className="h-32 w-full overflow-y-scroll" ref={moveListRef}>
<table className="table-compact table w-full"> <table className="table-compact table w-full">
<tbody>{getMoveListHtml()}</tbody> <tbody>{getMoveListHtml()}</tbody>

View File

@@ -35,6 +35,12 @@ export const startTournament = async (code: string): Promise<Tournament | null>
return res.json(); return res.json();
}; };
export const cancelTournament = async (code: string): Promise<Tournament | null> => {
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<Tournament | null> => { export const addBotToTournament = async (code: string, botName: string): Promise<Tournament | null> => {
const res = await fetch(`${API_URL}/v1/tournaments/${code}/add-bot`, { const res = await fetch(`${API_URL}/v1/tournaments/${code}/add-bot`, {
method: "POST", credentials: "include", method: "POST", credentials: "include",

View File

@@ -7,15 +7,16 @@ export interface StockfishLevel {
skillLevel: number; skillLevel: number;
depth: number; depth: number;
moveTime: number; moveTime: number;
randomChance: number; // 01 probability of playing a random legal move instead of Stockfish's best
} }
export const AI_LEVELS: StockfishLevel[] = [ export const AI_LEVELS: StockfishLevel[] = [
{ level: 1, name: "Anfänger", skillLevel: 0, depth: 1, moveTime: 100 }, { level: 1, name: "Anfänger", skillLevel: 0, depth: 1, moveTime: 50, randomChance: 0.80 },
{ level: 2, name: "Leicht", skillLevel: 3, depth: 3, moveTime: 200 }, { level: 2, name: "Leicht", skillLevel: 2, depth: 2, moveTime: 100, randomChance: 0.50 },
{ level: 3, name: "Mittel", skillLevel: 8, depth: 5, moveTime: 500 }, { level: 3, name: "Mittel", skillLevel: 7, depth: 4, moveTime: 300, randomChance: 0.15 },
{ level: 4, name: "Fortgeschritten", skillLevel: 14, depth: 10, moveTime: 1000 }, { level: 4, name: "Fortgeschritten", skillLevel: 13, depth: 8, moveTime: 800, randomChance: 0.03 },
{ level: 5, name: "Experte", skillLevel: 18, depth: 15, moveTime: 2000 }, { level: 5, name: "Experte", skillLevel: 18, depth: 14, moveTime: 1500, randomChance: 0 },
{ level: 6, name: "Meister", skillLevel: 20, depth: 20, moveTime: 3000 }, { level: 6, name: "Meister", skillLevel: 20, depth: 20, moveTime: 3000, randomChance: 0 },
]; ];
export class StockfishEngine { export class StockfishEngine {

View File

@@ -71,7 +71,8 @@ export const startTournament = async (req: Request, res: Response) => {
startedAt: Date.now(), startedAt: Date.now(),
timeControl: tc || undefined, timeControl: tc || undefined,
whiteTimeMs: tc ? tc * 60 * 1000 : 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); activeGames.push(game);
await TournamentModel.setRoundGameCode(row.id, code); 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(); } } 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) => { export const addBot = async (req: Request, res: Response) => {
if (!req.session.user?.id || typeof req.session.user.id === "string") { res.status(401).end(); return; } if (!req.session.user?.id || typeof req.session.user.id === "string") { res.status(401).end(); return; }
const { botName } = req.body; const { botName } = req.body;

View File

@@ -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]); 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<void> => { export type NextRoundData = { rows: any[]; timeControl: number | null } | null;
export const updateRoundResult = async (gameCode: string, result: "white" | "black" | "draw"): Promise<NextRoundData> => {
const roundRes = await db.query( const roundRes = await db.query(
`UPDATE "tournament_round" SET result=$1 WHERE game_code=$2 RETURNING *`, `UPDATE "tournament_round" SET result=$1 WHERE game_code=$2 RETURNING *`,
[result, gameCode] [result, gameCode]
); );
if (!roundRes.rows[0]) return; if (!roundRes.rows[0]) return null;
const round = roundRes.rows[0]; const round = roundRes.rows[0];
// Update scores // Update scores
@@ -164,15 +166,17 @@ export const updateRoundResult = async (gameCode: string, result: "white" | "bla
); );
if (parseInt(pendingRes.rows[0].count) === 0) { if (parseInt(pendingRes.rows[0].count) === 0) {
const nextRes = await db.query( 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] [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]); 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 { } else {
await db.query(`UPDATE "tournament" SET status='finished' WHERE id=$1`, [round.tournament_id]); 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 }; const TournamentModel = { createTournament, joinTournament, startTournament, findByCode, findAll, setRoundGameCode, updateRoundResult };

View File

@@ -1,5 +1,6 @@
import express from "express"; import express from "express";
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import { Chess } from "chess.js";
import { AI_LEVELS, getEngine } from "../controllers/stockfish.controller.js"; import { AI_LEVELS, getEngine } from "../controllers/stockfish.controller.js";
import { requireAuth } from "../middleware/auth.js"; import { requireAuth } from "../middleware/auth.js";
import GameModel from "../db/models/game.model.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 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) { } catch (err) {
console.error(err); console.error(err);
res.status(500).end(); res.status(500).end();

View File

@@ -1,5 +1,5 @@
import express from "express"; 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(); const router = express.Router();
router.post("/", createTournament); router.post("/", createTournament);
@@ -7,5 +7,6 @@ router.get("/", getTournaments);
router.get("/:code", getTournament); router.get("/:code", getTournament);
router.post("/:code/join", joinTournament); router.post("/:code/join", joinTournament);
router.post("/:code/start", startTournament); router.post("/:code/start", startTournament);
router.post("/:code/cancel", cancelTournament);
router.post("/:code/add-bot", addBot); router.post("/:code/add-bot", addBot);
export default router; export default router;

View File

@@ -1,13 +1,43 @@
import type { Game } from "@michess/types"; import type { Game } from "@michess/types";
import { Chess } from "chess.js"; import { Chess } from "chess.js";
import { nanoid } from "nanoid";
import type { DisconnectReason, Socket } from "socket.io"; import type { DisconnectReason, Socket } from "socket.io";
import GameModel, { activeGames } from "../db/models/game.model.js"; 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 { io } from "../server.js";
import { BOTS, BOT_NAMES } from "../bots.js"; import { BOTS, BOT_NAMES } from "../bots.js";
import { AI_LEVELS, getEngine } from "../controllers/stockfish.controller.js"; import { AI_LEVELS, getEngine } from "../controllers/stockfish.controller.js";
async function startNextRoundGames(nextRound: NonNullable<NextRoundData>) {
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 // TODO: clean up
async function triggerBotMove(game: Game, chess: Chess) { async function triggerBotMove(game: Game, chess: Chess) {
@@ -17,8 +47,17 @@ async function triggerBotMove(game: Game, chess: Chess) {
if (!bot || !game.code) return; if (!bot || !game.code) return;
const sfLevel = AI_LEVELS.find(l => l.level === bot.level) ?? AI_LEVELS[2]; 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; if (!bestMove) return;
// Check game still active // Check game still active
@@ -50,7 +89,15 @@ async function triggerBotMove(game: Game, chess: Chess) {
}); });
} }
if (chess.isGameOver()) { 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 const prevTurn = turn; // the bot was this turn
let reason: Game["endReason"]; let reason: Game["endReason"];
if (chess.isCheckmate()) reason = "checkmate"; if (chess.isCheckmate()) reason = "checkmate";
@@ -66,11 +113,13 @@ async function triggerBotMove(game: Game, chess: Chess) {
const saved = (await GameModel.save(game)) as Game; const saved = (await GameModel.save(game)) as Game;
game.id = saved?.id; game.id = saved?.id;
await TournamentModel.updateRoundResult(game.code, game.winner as "white" | "black" | "draw"); 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 }); io.to(game.code).emit("gameOver", { reason, winnerName, winnerSide, id: game.id });
if (game.timeout) clearTimeout(game.timeout); if (game.timeout) clearTimeout(game.timeout);
activeGames.splice(activeGames.indexOf(game), 1); 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) { 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; const { id } = (await GameModel.save(game)) as Game;
game.id = id; 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 = { const gameOver = {
reason: game.endReason, reason: game.endReason,
@@ -245,6 +294,7 @@ export async function claimAbandoned(this: Socket, type: "win" | "draw") {
if (game.timeout) clearTimeout(game.timeout); if (game.timeout) clearTimeout(game.timeout);
activeGames.splice(activeGames.indexOf(game), 1); activeGames.splice(activeGames.indexOf(game), 1);
if (nextRound1) await startNextRoundGames(nextRound1);
} }
// eslint-disable-next-line no-unused-vars // 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 saved = (await GameModel.save(game)) as Game;
const id = saved?.id; const id = saved?.id;
game.id = 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 }); io.to(game.code as string).emit("gameOver", { reason: "timeout", winnerSide: game.winner, id });
if (game.timeout) clearTimeout(game.timeout); if (game.timeout) clearTimeout(game.timeout);
activeGames.splice(activeGames.indexOf(game), 1); activeGames.splice(activeGames.indexOf(game), 1);
if (nextRoundT) await startNextRoundGames(nextRoundT);
return; 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 const { id } = (await GameModel.save(game)) as Game; // save game to db
game.id = id; 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 }); io.to(game.code as string).emit("gameOver", { reason, winnerName, winnerSide, id });
if (game.timeout) clearTimeout(game.timeout); if (game.timeout) clearTimeout(game.timeout);
activeGames.splice(activeGames.indexOf(game), 1); activeGames.splice(activeGames.indexOf(game), 1);
if (nextRoundM) await startNextRoundGames(nextRoundM);
} }
} else { } else {
throw new Error("invalid move"); throw new Error("invalid move");
@@ -440,8 +492,9 @@ export async function claimTimeout(this: Socket) {
const saved = (await GameModel.save(game)) as Game; const saved = (await GameModel.save(game)) as Game;
const id = saved?.id; const id = saved?.id;
game.id = 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 }); io.to(game.code as string).emit("gameOver", { reason: "timeout", winnerSide: game.winner, id });
if (game.timeout) clearTimeout(game.timeout); if (game.timeout) clearTimeout(game.timeout);
activeGames.splice(activeGames.indexOf(game), 1); activeGames.splice(activeGames.indexOf(game), 1);
if (nextRoundCT) await startNextRoundGames(nextRoundCT);
} }

1
types/index.d.ts vendored
View File

@@ -18,6 +18,7 @@ export interface Game {
whiteTimeMs?: number; whiteTimeMs?: number;
blackTimeMs?: number; blackTimeMs?: number;
lastMoveAt?: number; lastMoveAt?: number;
tournamentCode?: string;
} }
export interface User { export interface User {