Skip to content

Commit 0668f90

Browse files
committed
update play with computer feature
1 parent 5ad1672 commit 0668f90

File tree

17 files changed

+641
-704
lines changed

17 files changed

+641
-704
lines changed

backend/.eslintrc.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
module.exports = {
2+
parser: "@typescript-eslint/parser",
3+
parserOptions: {
4+
project: "./tsconfig.json",
5+
tsconfigRootDir: __dirname,
6+
},
7+
};

backend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"passport": "^0.7.0",
3434
"passport-google-oauth20": "^2.0.0",
3535
"redis": "^5.6.0",
36+
"stockfish": "^17.1.0",
3637
"swagger-jsdoc": "^6.2.8",
3738
"swagger-ui-express": "^5.0.1",
3839
"ts-node": "^10.9.2",
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterEnum
2+
ALTER TYPE "public"."RoomType" ADD VALUE 'BOT';

backend/prisma/schema.prisma

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ enum RoomStatus {
108108
}
109109

110110
enum RoomType {
111+
BOT
111112
PUBLIC
112113
PRIVATE
113114
}

backend/src/games/services/bot.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { Chess, Square } from "chess.js";
2+
import { Game, GameStatus } from "../../lib/types";
3+
import { logger } from "../../services/logger";
4+
import { StockfishEngine } from "./engine";
5+
import { GameService } from "./game";
6+
7+
export const BOT_PLAYER_ID = "bot-player-001";
8+
export const BOT_NAME = "Computer";
9+
10+
export class BotService {
11+
private gameService: GameService;
12+
private engine: StockfishEngine;
13+
14+
constructor(gameService: GameService) {
15+
this.gameService = gameService;
16+
this.engine = new StockfishEngine();
17+
logger.info("BotService initialized with Stockfish engine.");
18+
}
19+
20+
public async onGameUpdate(game: Game): Promise<void> {
21+
if (game.status !== GameStatus.ACTIVE || !this.isBotTurn(game)) {
22+
return;
23+
}
24+
25+
const thinkingTime = this.getThinkingTime();
26+
logger.info(`Bot is thinking for ~${thinkingTime}ms in game ${game.id}...`);
27+
28+
try {
29+
await this.makeBotMove(game, thinkingTime);
30+
} catch (error) {
31+
logger.error(`Bot failed to make a move in game ${game.id}:`, error);
32+
}
33+
}
34+
35+
private isBotTurn(game: Game): boolean {
36+
const botPlayer = game.players.find((p) => p.userId === BOT_PLAYER_ID);
37+
if (!botPlayer) return false;
38+
39+
const chess = new Chess(game.fen);
40+
return botPlayer.color.startsWith(chess.turn());
41+
}
42+
43+
private async makeBotMove(game: Game, moveTime: number): Promise<void> {
44+
const bestMoveUCI = await this.engine.findBestMove(game.fen, moveTime);
45+
46+
if (!bestMoveUCI || bestMoveUCI === "(none)") {
47+
logger.warn(`Stockfish returned no move for game ${game.id}`);
48+
return;
49+
}
50+
51+
const from = bestMoveUCI.substring(0, 2) as Square;
52+
const to = bestMoveUCI.substring(2, 4) as Square;
53+
const promotion =
54+
bestMoveUCI.length === 5 ? bestMoveUCI.substring(4) : undefined;
55+
56+
const move = { from, to, promotion };
57+
58+
logger.info(`Bot is making move ${bestMoveUCI} in game ${game.id}.`);
59+
60+
await this.gameService.makeMove(game.id, BOT_PLAYER_ID, move);
61+
}
62+
63+
private getThinkingTime(): number {
64+
return Math.floor(Math.random() * 2500) + 1500;
65+
}
66+
67+
public shutdown(): void {
68+
logger.info("Shutting down BotService and Stockfish engine.");
69+
this.engine.quit();
70+
}
71+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { spawn, ChildProcessWithoutNullStreams } from "child_process";
2+
import { logger } from "../../services/logger";
3+
4+
export class StockfishEngine {
5+
private stockfish: ChildProcessWithoutNullStreams;
6+
private isReady = false;
7+
private listeners: ((message: string) => void)[] = [];
8+
9+
constructor() {
10+
const stockfishPath = require.resolve(
11+
"stockfish/src/stockfish-17.1-8e4d048.js"
12+
);
13+
14+
this.stockfish = spawn("node", [stockfishPath]);
15+
16+
this.initializeListeners();
17+
}
18+
19+
private initializeListeners(): void {
20+
this.stockfish.stdout.on("data", (data: Buffer) => {
21+
const messages = data.toString().split("\n").filter(Boolean);
22+
for (const message of messages) {
23+
const trimmedMessage = message.trim();
24+
if (trimmedMessage.startsWith("uciok")) {
25+
this.isReady = true;
26+
logger.info("Stockfish engine is ready.");
27+
}
28+
this.listeners.forEach((listener) => listener(trimmedMessage));
29+
}
30+
});
31+
32+
this.stockfish.stderr.on("data", (data: Buffer) => {
33+
logger.error(`Stockfish stderr: ${data.toString().trim()}`);
34+
});
35+
36+
this.stockfish.on("close", (code) => {
37+
if (code !== 0) {
38+
logger.error(`Stockfish process exited with code ${code}`);
39+
}
40+
});
41+
42+
this.send("uci");
43+
}
44+
45+
private send(command: string): void {
46+
this.stockfish.stdin.write(`${command}\n`);
47+
}
48+
49+
public findBestMove(fen: string, moveTime: number): Promise<string> {
50+
return new Promise((resolve, reject) => {
51+
if (!this.isReady) {
52+
return setTimeout(() => {
53+
this.findBestMove(fen, moveTime).then(resolve).catch(reject);
54+
}, 500);
55+
}
56+
57+
const onMessage = (message: string) => {
58+
if (message.startsWith("bestmove")) {
59+
const bestMove = message.split(" ")[1];
60+
// Clean up this specific listener
61+
this.listeners = this.listeners.filter((l) => l !== onMessage);
62+
resolve(bestMove);
63+
}
64+
};
65+
66+
this.listeners.push(onMessage);
67+
68+
this.send(`position fen ${fen}`);
69+
this.send(`go movetime ${moveTime}`);
70+
});
71+
}
72+
73+
public quit(): void {
74+
this.send("quit");
75+
}
76+
}

backend/src/games/services/game.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
/* eslint-disable @typescript-eslint/no-explicit-any */
2-
3-
// game file
42
import { Chess, Square } from "chess.js";
53
import { InputJsonValue } from "@prisma/client/runtime/library";
64

@@ -20,18 +18,25 @@ import { WebSocketService } from "../../services/websocket";
2018
import { redis } from "../../services/redis";
2119
import { UserStatus } from "@prisma/client";
2220
import { logger } from "../../services/logger";
21+
import { BOT_PLAYER_ID, BotService } from "./bot";
2322

2423
export class GameService {
2524
private ws: WebSocketService;
25+
private botService: BotService;
2626

2727
private activeGames: Set<string> = new Set();
2828
private masterTimer: NodeJS.Timeout | null = null;
2929

3030
constructor(ws: WebSocketService) {
3131
this.ws = ws;
32+
this.botService = new BotService(this);
3233
this.startMasterTimer();
3334
}
3435

36+
public getBotService(): BotService {
37+
return this.botService;
38+
}
39+
3540
private startMasterTimer(): void {
3641
if (this.masterTimer) return;
3742

@@ -320,7 +325,9 @@ export class GameService {
320325
const player = game.players.find((p) => p.userId === playerId);
321326

322327
if (!player || chess.turn() !== player.color[0]) {
323-
throw new Error("Not your turn");
328+
if (playerId !== BOT_PLAYER_ID) {
329+
throw new Error("Not your turn");
330+
}
324331
}
325332

326333
const result = chess.move({
@@ -372,7 +379,7 @@ export class GameService {
372379
if (chess.isCheckmate()) {
373380
this.removeGameFromTimer(gameId);
374381
game.status = GameStatus.COMPLETED;
375-
game.winnerId = player.userId;
382+
game.winnerId = player?.userId;
376383
} else if (chess.isDraw()) {
377384
this.removeGameFromTimer(gameId);
378385
game.status = GameStatus.DRAW;
@@ -409,6 +416,8 @@ export class GameService {
409416
await redis.setJSON(`game:${gameId}`, game);
410417

411418
this.ws.broadcastToGame(game);
419+
420+
await this.botService.onGameUpdate(game);
412421
}
413422

414423
async getLegalMoves(

0 commit comments

Comments
 (0)