feat(bots): add named bot profiles with ELO-based difficulty

- 5 bot profiles (Holzpferd Heinz to Meister Magnus, ELO 200-1400) in
  server/bots.ts and client/bots.ts with emoji + descriptions
- Bot users auto-created in DB on server startup (role='bot')
- AI page replaced difficulty buttons with bot profile cards
- Tournament host can add bots via dropdown (POST /:code/add-bot)
- Bot auto-moves triggered on joinLobby and after each human move
- Fixed clock start order so bot-as-white games initialize correctly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Michess
2026-04-14 13:32:43 +02:00
parent b29c467766
commit 157c14c11e
9 changed files with 191 additions and 20 deletions

8
server/src/bots.ts Normal file
View File

@@ -0,0 +1,8 @@
export const BOTS = [
{ name: "Holzpferd Heinz", elo: 200, level: 1 },
{ name: "Bauernschubser Bert", elo: 500, level: 2 },
{ name: "Taktiker Theo", elo: 800, level: 3 },
{ name: "Kombinationskarl", elo: 1100, level: 4 },
{ name: "Meister Magnus", elo: 1400, level: 5 },
] as const;
export const BOT_NAMES = new Set<string>(BOTS.map(b => b.name));

View File

@@ -1,6 +1,7 @@
import type { Request, Response } from "express";
import TournamentModel from "../db/models/tournament.model.js";
import { activeGames } from "../db/models/game.model.js";
import { db } from "../db/index.js";
import { nanoid } from "nanoid";
import type { Game } from "@michess/types";
@@ -80,3 +81,22 @@ export const startTournament = async (req: Request, res: Response) => {
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;
if (!botName) { res.status(400).json({ message: "Bot name erforderlich." }); 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; }
const botRes = await db.query(`SELECT id, name FROM "user" WHERE name=$1 AND role='bot'`, [botName]);
if (!botRes.rows[0]) { res.status(404).json({ message: "Bot nicht gefunden." }); return; }
const bot = botRes.rows[0];
const result = await TournamentModel.joinTournament(req.params.code, bot.id, bot.name);
if (!result) { res.status(400).json({ message: "Bot konnte nicht hinzugefügt werden." }); return; }
const updated = await TournamentModel.findByCode(req.params.code);
res.status(200).json(updated);
} catch (e) { console.error(e); res.status(500).end(); }
};

View File

@@ -1,5 +1,5 @@
import express from "express";
import { createTournament, getTournaments, getTournament, joinTournament, startTournament } from "../controllers/tournament.controller.js";
import { createTournament, getTournaments, getTournament, joinTournament, startTournament, addBot } from "../controllers/tournament.controller.js";
const router = express.Router();
router.post("/", createTournament);
@@ -7,4 +7,5 @@ router.get("/", getTournaments);
router.get("/:code", getTournament);
router.post("/:code/join", joinTournament);
router.post("/:code/start", startTournament);
router.post("/:code/add-bot", addBot);
export default router;

View File

@@ -9,6 +9,7 @@ import { INIT_TABLES, db } from "./db/index.js";
import session from "./middleware/session.js";
import routes from "./routes/index.js";
import { init as initSocket } from "./socket/index.js";
import { BOTS } from "./bots.js";
const corsConfig = {
origin: process.env.CORS_ORIGIN || "http://localhost:3000",
@@ -39,6 +40,14 @@ db.query(INIT_TABLES, async (err) => {
console.error("Failed to set admin role:", e);
}
}
// Create bot users if not exist
for (const bot of BOTS) {
await db.query(
`INSERT INTO "user" (name, role) VALUES ($1, 'bot') ON CONFLICT (name) DO NOTHING`,
[bot.name]
);
}
}
});

View File

@@ -5,9 +5,74 @@ 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 { io } from "../server.js";
import { BOTS, BOT_NAMES } from "../bots.js";
import { AI_LEVELS, getEngine } from "../controllers/stockfish.controller.js";
// TODO: clean up
async function triggerBotMove(game: Game, chess: Chess) {
const turn = chess.turn();
const botPlayer = turn === "w" ? game.white : game.black;
const bot = BOTS.find(b => b.name === botPlayer?.name);
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);
if (!bestMove) return;
// Check game still active
if (!activeGames.find(g => g.code === game.code)) return;
const from = bestMove.slice(0, 2);
const to = bestMove.slice(2, 4);
const promotion = bestMove[4] ?? "q";
// Time deduction for bot
if (game.timeControl && game.lastMoveAt !== undefined && game.whiteTimeMs !== undefined && game.blackTimeMs !== undefined) {
const elapsed = Date.now() - game.lastMoveAt;
if (turn === "w") game.whiteTimeMs = Math.max(0, game.whiteTimeMs - elapsed);
else game.blackTimeMs = Math.max(0, game.blackTimeMs - elapsed);
game.lastMoveAt = Date.now();
}
const result = chess.move({ from, to, promotion });
if (!result) return;
game.pgn = chess.pgn();
io.to(game.code).emit("receivedMove", { from, to, promotion });
if (game.timeControl) {
io.to(game.code).emit("clockUpdate", {
whiteTimeMs: game.whiteTimeMs,
blackTimeMs: game.blackTimeMs,
lastMoveAt: game.lastMoveAt
});
}
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);
}
}
export async function joinLobby(this: Socket, gameCode: string) {
const game = activeGames.find((g) => g.code === gameCode);
if (!game) return;
@@ -51,6 +116,12 @@ export async function joinLobby(this: Socket, gameCode: string) {
await this.join(gameCode);
io.to(game.code as string).emit("receivedLatestGame", game);
// Mark bots as connected so clock start and bot trigger logic see both players ready
const chess = new Chess();
if (game.pgn) chess.loadPgn(game.pgn);
if (BOT_NAMES.has(game.white?.name ?? "")) game.white!.connected = true;
if (BOT_NAMES.has(game.black?.name ?? "")) game.black!.connected = true;
// Start clock if both players are connected and game has timeControl but hasn't started timing yet
if (game.timeControl && game.white?.connected && game.black?.connected && !game.lastMoveAt && !game.pgn) {
game.lastMoveAt = Date.now();
@@ -60,6 +131,15 @@ export async function joinLobby(this: Socket, gameCode: string) {
lastMoveAt: game.lastMoveAt
});
}
if (game.white?.connected && game.black?.connected && !chess.isGameOver()) {
const nextTurn = chess.turn();
const nextPlayer = nextTurn === "w" ? game.white : game.black;
if (BOT_NAMES.has(nextPlayer?.name ?? "")) {
// Slight delay so client receives the current game state first
setTimeout(() => triggerBotMove(game, chess), 500);
}
}
}
export async function leaveLobby(this: Socket, reason?: DisconnectReason, code?: string) {
@@ -233,6 +313,13 @@ export async function sendMove(this: Socket, m: { from: string; to: string; prom
});
}
// Trigger bot move if next player is a bot
if (!chess.isGameOver() && BOT_NAMES.has((chess.turn() === "w" ? game.white : game.black)?.name ?? "")) {
const chessCopy = new Chess();
chessCopy.loadPgn(chess.pgn());
setTimeout(() => triggerBotMove(game, chessCopy), 300);
}
if (chess.isGameOver()) {
let reason: Game["endReason"];
if (chess.isCheckmate()) reason = "checkmate";