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:
@@ -5,20 +5,13 @@ import { Chessboard } from "react-chessboard";
|
|||||||
import { Chess } from "chess.js";
|
import { Chess } from "chess.js";
|
||||||
import { API_URL } from "@/config";
|
import { API_URL } from "@/config";
|
||||||
import { useSession } from "@/context/session";
|
import { useSession } from "@/context/session";
|
||||||
|
import { BOTS } from "@/bots";
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
|
|
||||||
const AI_LEVELS = [
|
|
||||||
{ level: 1, name: "Anfänger" },
|
|
||||||
{ level: 2, name: "Leicht" },
|
|
||||||
{ level: 3, name: "Mittel" },
|
|
||||||
{ level: 4, name: "Fortgeschritten" },
|
|
||||||
{ level: 5, name: "Experte" },
|
|
||||||
{ level: 6, name: "Meister" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function AiGamePage() {
|
export default function AiGamePage() {
|
||||||
const { user } = useSession();
|
const { user } = useSession();
|
||||||
const [selectedLevel, setSelectedLevel] = useState(3);
|
const [selectedBot, setSelectedBot] = useState(BOTS[2]);
|
||||||
|
const selectedLevel = selectedBot.level;
|
||||||
const [gameStarted, setGameStarted] = useState(false);
|
const [gameStarted, setGameStarted] = useState(false);
|
||||||
const [game, setGame] = useState(new Chess());
|
const [game, setGame] = useState(new Chess());
|
||||||
const [playerColor, setPlayerColor] = useState<"white" | "black">("white");
|
const [playerColor, setPlayerColor] = useState<"white" | "black">("white");
|
||||||
@@ -152,15 +145,20 @@ export default function AiGamePage() {
|
|||||||
|
|
||||||
<div className="card bg-base-200 shadow p-6 w-full flex flex-col gap-5">
|
<div className="card bg-base-200 shadow p-6 w-full flex flex-col gap-5">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold mb-3">Schwierigkeitsgrad</h2>
|
<h2 className="text-lg font-semibold mb-3">Gegner wählen</h2>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="grid grid-cols-1 gap-2">
|
||||||
{AI_LEVELS.map((l) => (
|
{BOTS.map((b) => (
|
||||||
<button
|
<button
|
||||||
key={l.level}
|
key={b.name}
|
||||||
className={`btn ${selectedLevel === l.level ? "btn-primary" : "btn-outline"}`}
|
className={`flex items-center gap-3 p-3 rounded-lg border-2 text-left transition-colors ${selectedBot.name === b.name ? "border-primary bg-primary/10" : "border-base-300 hover:border-primary/50"}`}
|
||||||
onClick={() => setSelectedLevel(l.level)}
|
onClick={() => setSelectedBot(b)}
|
||||||
>
|
>
|
||||||
{l.level}. {l.name}
|
<span className="text-2xl">{b.emoji}</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-semibold">{b.name}</div>
|
||||||
|
<div className="text-xs opacity-60">{b.description}</div>
|
||||||
|
</div>
|
||||||
|
<span className="badge badge-ghost badge-sm font-mono">{b.elo}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -196,7 +194,7 @@ export default function AiGamePage() {
|
|||||||
|
|
||||||
<div className="flex flex-col gap-4 min-w-[200px]">
|
<div className="flex flex-col gap-4 min-w-[200px]">
|
||||||
<div className="card bg-base-200 shadow p-4">
|
<div className="card bg-base-200 shadow p-4">
|
||||||
<p className="font-semibold text-sm opacity-70">Niveau: {AI_LEVELS.find(l => l.level === selectedLevel)?.name}</p>
|
<p className="font-semibold text-sm opacity-70">Gegner: {selectedBot.emoji} {selectedBot.name}</p>
|
||||||
<p className="font-semibold text-sm opacity-70">Du spielst: {playerColor === "white" ? "Weiß ♔" : "Schwarz ♚"}</p>
|
<p className="font-semibold text-sm opacity-70">Du spielst: {playerColor === "white" ? "Weiß ♔" : "Schwarz ♚"}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import InviteFriendsModal from "@/components/InviteFriendsModal";
|
import InviteFriendsModal from "@/components/InviteFriendsModal";
|
||||||
import { useSession } from "@/context/session";
|
import { useSession } from "@/context/session";
|
||||||
import { fetchTournament, joinTournament, startTournament } from "@/lib/tournament";
|
import { fetchTournament, joinTournament, startTournament, addBotToTournament } from "@/lib/tournament";
|
||||||
|
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";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
@@ -16,6 +17,7 @@ export default function TournamentDetailPage() {
|
|||||||
const [tournament, setTournament] = useState<Tournament | null>(null);
|
const [tournament, setTournament] = useState<Tournament | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [actionLoading, setActionLoading] = useState(false);
|
const [actionLoading, setActionLoading] = useState(false);
|
||||||
|
const [selectedBot, setSelectedBot] = useState(BOTS[0].name);
|
||||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
@@ -47,6 +49,13 @@ export default function TournamentDetailPage() {
|
|||||||
setActionLoading(false);
|
setActionLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleAddBot() {
|
||||||
|
setActionLoading(true);
|
||||||
|
const updated = await addBotToTournament(code, selectedBot);
|
||||||
|
if (updated) setTournament(updated);
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleStart() {
|
async function handleStart() {
|
||||||
if (!user?.id) return;
|
if (!user?.id) return;
|
||||||
setActionLoading(true);
|
setActionLoading(true);
|
||||||
@@ -123,6 +132,27 @@ export default function TournamentDetailPage() {
|
|||||||
{tournament.status === "waiting" && isJoined && (
|
{tournament.status === "waiting" && isJoined && (
|
||||||
<InviteFriendsModal gameCode={`tournament/${code}`} label="Freunde einladen" />
|
<InviteFriendsModal gameCode={`tournament/${code}`} label="Freunde einladen" />
|
||||||
)}
|
)}
|
||||||
|
{isHost && tournament.status === "waiting" && (
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<select
|
||||||
|
className="select select-sm select-bordered"
|
||||||
|
value={selectedBot}
|
||||||
|
onChange={(e) => setSelectedBot(e.target.value)}
|
||||||
|
disabled={actionLoading}
|
||||||
|
>
|
||||||
|
{BOTS.map((b) => (
|
||||||
|
<option key={b.name} value={b.name}>{b.emoji} {b.name} ({b.elo})</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
className={"btn btn-outline btn-sm" + (actionLoading ? " loading" : "")}
|
||||||
|
onClick={handleAddBot}
|
||||||
|
disabled={actionLoading}
|
||||||
|
>
|
||||||
|
Bot hinzufügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{canStart && (
|
{canStart && (
|
||||||
<button
|
<button
|
||||||
className={"btn btn-primary btn-sm" + (actionLoading ? " loading" : "")}
|
className={"btn btn-primary btn-sm" + (actionLoading ? " loading" : "")}
|
||||||
|
|||||||
8
client/src/bots.ts
Normal file
8
client/src/bots.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export const BOTS = [
|
||||||
|
{ name: "Holzpferd Heinz", elo: 200, level: 1, emoji: "🐴", description: "Perfekt für Einsteiger" },
|
||||||
|
{ name: "Bauernschubser Bert", elo: 500, level: 2, emoji: "♟", description: "Lernt die Grundzüge" },
|
||||||
|
{ name: "Taktiker Theo", elo: 800, level: 3, emoji: "🧩", description: "Kennt taktische Muster" },
|
||||||
|
{ name: "Kombinationskarl", elo: 1100, level: 4, emoji: "⚡", description: "Gefährlicher Angreifer" },
|
||||||
|
{ name: "Meister Magnus", elo: 1400, level: 5, emoji: "👑", description: "Fast unschlagbar" },
|
||||||
|
];
|
||||||
|
export type Bot = typeof BOTS[0];
|
||||||
@@ -34,3 +34,13 @@ export const startTournament = async (code: string): Promise<Tournament | null>
|
|||||||
if (!res.ok) return null;
|
if (!res.ok) return null;
|
||||||
return res.json();
|
return res.json();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const addBotToTournament = async (code: string, botName: string): Promise<Tournament | null> => {
|
||||||
|
const res = await fetch(`${API_URL}/v1/tournaments/${code}/add-bot`, {
|
||||||
|
method: "POST", credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ botName })
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|||||||
8
server/src/bots.ts
Normal file
8
server/src/bots.ts
Normal 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));
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import TournamentModel from "../db/models/tournament.model.js";
|
import TournamentModel from "../db/models/tournament.model.js";
|
||||||
import { activeGames } from "../db/models/game.model.js";
|
import { activeGames } from "../db/models/game.model.js";
|
||||||
|
import { db } from "../db/index.js";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import type { Game } from "@michess/types";
|
import type { Game } from "@michess/types";
|
||||||
|
|
||||||
@@ -80,3 +81,22 @@ export const startTournament = async (req: Request, res: Response) => {
|
|||||||
res.status(200).json(updated);
|
res.status(200).json(updated);
|
||||||
} catch (e) { console.error(e); res.status(500).end(); }
|
} 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(); }
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import express from "express";
|
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();
|
const router = express.Router();
|
||||||
router.post("/", createTournament);
|
router.post("/", createTournament);
|
||||||
@@ -7,4 +7,5 @@ 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/add-bot", addBot);
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { INIT_TABLES, db } from "./db/index.js";
|
|||||||
import session from "./middleware/session.js";
|
import session from "./middleware/session.js";
|
||||||
import routes from "./routes/index.js";
|
import routes from "./routes/index.js";
|
||||||
import { init as initSocket } from "./socket/index.js";
|
import { init as initSocket } from "./socket/index.js";
|
||||||
|
import { BOTS } from "./bots.js";
|
||||||
|
|
||||||
const corsConfig = {
|
const corsConfig = {
|
||||||
origin: process.env.CORS_ORIGIN || "http://localhost:3000",
|
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);
|
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]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,74 @@ 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 from "../db/models/tournament.model.js";
|
||||||
import { io } from "../server.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
|
// 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) {
|
export async function joinLobby(this: Socket, gameCode: string) {
|
||||||
const game = activeGames.find((g) => g.code === gameCode);
|
const game = activeGames.find((g) => g.code === gameCode);
|
||||||
if (!game) return;
|
if (!game) return;
|
||||||
@@ -51,6 +116,12 @@ export async function joinLobby(this: Socket, gameCode: string) {
|
|||||||
await this.join(gameCode);
|
await this.join(gameCode);
|
||||||
io.to(game.code as string).emit("receivedLatestGame", game);
|
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
|
// 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) {
|
if (game.timeControl && game.white?.connected && game.black?.connected && !game.lastMoveAt && !game.pgn) {
|
||||||
game.lastMoveAt = Date.now();
|
game.lastMoveAt = Date.now();
|
||||||
@@ -60,6 +131,15 @@ export async function joinLobby(this: Socket, gameCode: string) {
|
|||||||
lastMoveAt: game.lastMoveAt
|
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) {
|
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()) {
|
if (chess.isGameOver()) {
|
||||||
let reason: Game["endReason"];
|
let reason: Game["endReason"];
|
||||||
if (chess.isCheckmate()) reason = "checkmate";
|
if (chess.isCheckmate()) reason = "checkmate";
|
||||||
|
|||||||
Reference in New Issue
Block a user