Skip to content

Commit 349e24c

Browse files
committed
feat: play with bot
1 parent 000ab36 commit 349e24c

File tree

13 files changed

+531
-433
lines changed

13 files changed

+531
-433
lines changed
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { Chess, Move as ChessMove } from "chess.js";
2+
3+
const evaluateBoard = (chess: Chess): number => {
4+
const pieceValues: { [key: string]: number } = {
5+
p: 1,
6+
n: 3,
7+
b: 3,
8+
r: 5,
9+
q: 9,
10+
k: 0,
11+
};
12+
13+
let totalEvaluation = 0;
14+
const board = chess.board();
15+
16+
for (let i = 0; i < 8; i++) {
17+
for (let j = 0; j < 8; j++) {
18+
const piece = board[i][j];
19+
if (piece) {
20+
const value = pieceValues[piece.type] * (piece.color === "w" ? 1 : -1);
21+
totalEvaluation += value;
22+
}
23+
}
24+
}
25+
return totalEvaluation;
26+
};
27+
28+
const minimax = (
29+
chess: Chess,
30+
depth: number,
31+
isMaximizing: boolean,
32+
alpha: number = -Infinity,
33+
beta: number = Infinity
34+
): number => {
35+
if (depth === 0 || chess.isGameOver()) return evaluateBoard(chess);
36+
37+
const possibleMoves = chess.moves();
38+
let bestValue = isMaximizing ? -Infinity : Infinity;
39+
40+
for (const move of possibleMoves) {
41+
chess.move(move);
42+
const value = minimax(chess, depth - 1, !isMaximizing, alpha, beta);
43+
chess.undo();
44+
45+
if (isMaximizing) {
46+
bestValue = Math.max(bestValue, value);
47+
alpha = Math.max(alpha, value);
48+
} else {
49+
bestValue = Math.min(bestValue, value);
50+
beta = Math.min(beta, value);
51+
}
52+
if (beta <= alpha) break;
53+
}
54+
return bestValue;
55+
};
56+
57+
export const findBestMove = (chess: Chess, depth: number): ChessMove | null => {
58+
const possibleMoves = chess.moves({ verbose: true });
59+
if (possibleMoves.length === 0) return null;
60+
61+
let bestMove: ChessMove | null = null;
62+
63+
let bestValue = -Infinity;
64+
const isBotMaximizing = chess.turn() === "w";
65+
66+
for (const move of possibleMoves) {
67+
chess.move(move.san);
68+
const boardValue = minimax(
69+
chess,
70+
depth - 1,
71+
!isBotMaximizing,
72+
-Infinity,
73+
Infinity
74+
);
75+
chess.undo();
76+
77+
const moveScore = isBotMaximizing ? boardValue : -boardValue;
78+
79+
if (moveScore > bestValue) {
80+
bestValue = moveScore;
81+
bestMove = move;
82+
}
83+
}
84+
85+
if (bestMove && Math.random() < 0.2) {
86+
bestMove = possibleMoves[Math.floor(Math.random() * possibleMoves.length)];
87+
}
88+
89+
return (
90+
bestMove || possibleMoves[Math.floor(Math.random() * possibleMoves.length)]
91+
);
92+
};

backend/src/games/services/game.ts

Lines changed: 184 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Chess, Square } from "chess.js";
33
import { InputJsonValue } from "@prisma/client/runtime/library";
44

55
import {
6+
AuthProvider,
67
ChatMessage,
78
Game,
89
GameStatus,
@@ -18,6 +19,7 @@ import { WebSocketService } from "../../services/websocket";
1819
import { redis } from "../../services/redis";
1920
import { UserStatus } from "@prisma/client";
2021
import { logger } from "../../services/logger";
22+
import { findBestMove } from "./bot/chess-bot";
2123

2224
export class GameService {
2325
private ws: WebSocketService;
@@ -97,6 +99,47 @@ export class GameService {
9799
logger.info("Master game timer has started");
98100
}
99101

102+
private async _persistGameEnd(game: Game): Promise<void> {
103+
this.removeGameFromTimer(game.id);
104+
105+
try {
106+
await prisma.$transaction(
107+
async (tx) => {
108+
await tx.game.update({
109+
where: { id: game.id },
110+
data: {
111+
status: game.status,
112+
winnerId: game.winnerId,
113+
fen: game.fen,
114+
moveHistory: game.moveHistory as unknown as InputJsonValue[],
115+
timers: game.timers as unknown as InputJsonValue,
116+
},
117+
});
118+
119+
await tx.room.update({
120+
where: { id: game.roomId },
121+
data: { status: RoomStatus.CLOSED },
122+
});
123+
124+
await tx.user.updateMany({
125+
where: { id: { in: game.players.map((p) => p.userId) } },
126+
data: { status: UserStatus.ONLINE },
127+
});
128+
},
129+
{
130+
maxWait: 10000,
131+
timeout: 20000,
132+
}
133+
);
134+
135+
logger.info(
136+
`Game ${game.id} ended and has been persisted to the database.`
137+
);
138+
} catch (error) {
139+
logger.error(`Failed to persist game end for ${game.id}:`, error);
140+
}
141+
}
142+
100143
public addGameToTimer(gameId: string) {
101144
this.activeGames.add(gameId);
102145
logger.info(`Game ${gameId} added to master timer.`);
@@ -153,7 +196,7 @@ export class GameService {
153196
players: dbGame.players.map((p) => ({
154197
userId: p.userId,
155198
color: p.color,
156-
name: p.user.name,
199+
name: p.user.name!,
157200
})),
158201
chat: dbGame.chat as unknown as ChatMessage[],
159202
winnerId: dbGame.winnerId || undefined,
@@ -306,6 +349,89 @@ export class GameService {
306349
return gameData;
307350
}
308351

352+
async startBotGame(playerId: string): Promise<Game> {
353+
const humanPlayer = await prisma.user.findUnique({
354+
where: {
355+
id: playerId,
356+
},
357+
});
358+
359+
if (!humanPlayer) throw new Error("Player not found");
360+
361+
let botUser = await prisma.user.findUnique({
362+
where: {
363+
username: "ChessBot",
364+
},
365+
});
366+
if (!botUser) {
367+
botUser = await prisma.user.create({
368+
data: {
369+
username: "ChessBot",
370+
name: "Computer",
371+
email: `bot@${Date.now()}.chess`,
372+
provider: AuthProvider.GUEST,
373+
elo: 1400,
374+
},
375+
});
376+
}
377+
378+
const room = await prisma.room.create({
379+
data: {
380+
type: RoomType.PRIVATE,
381+
status: RoomStatus.ACTIVE,
382+
players: [
383+
{ id: humanPlayer.id, color: "white" },
384+
{ id: botUser.id, color: "black" },
385+
],
386+
},
387+
});
388+
389+
const chess = new Chess();
390+
const timeControl: TimeControl = { initial: 600, increment: 0 };
391+
392+
const game = await prisma.game.create({
393+
data: {
394+
roomId: room.id,
395+
fen: chess.fen(),
396+
status: GameStatus.ACTIVE,
397+
timers: { white: timeControl.initial, black: timeControl.initial },
398+
timeControl: timeControl as unknown as InputJsonValue,
399+
players: {
400+
create: [
401+
{ userId: humanPlayer.id, color: "white" },
402+
{ userId: botUser.id, color: "black" },
403+
],
404+
},
405+
},
406+
});
407+
408+
const gameData: Game = {
409+
id: game.id,
410+
roomId: game.roomId,
411+
fen: game.fen,
412+
moveHistory: [],
413+
timers: game.timers as { white: number; black: number },
414+
timeControl: game.timeControl as unknown as TimeControl,
415+
status: game.status as GameStatus,
416+
players: [
417+
{
418+
userId: humanPlayer.id,
419+
color: "white",
420+
name: humanPlayer.name || "Player",
421+
},
422+
{ userId: botUser.id, color: "black", name: "Computer" },
423+
],
424+
chat: [],
425+
winnerId: undefined,
426+
createdAt: game.createdAt,
427+
};
428+
429+
await redis.setJSON(`game:${game.id}`, gameData);
430+
this.addGameToTimer(game.id);
431+
432+
return gameData;
433+
}
434+
309435
async makeMove(
310436
gameId: string,
311437
playerId: string,
@@ -377,36 +503,68 @@ export class GameService {
377503
}
378504

379505
if (game.status !== GameStatus.ACTIVE) {
380-
await prisma.$transaction(async (tx) => {
381-
await tx.game.update({
382-
where: {
383-
id: gameId,
384-
},
385-
data: {
386-
fen: game.fen,
387-
moveHistory: game.moveHistory as unknown as InputJsonValue[],
388-
status: game.status,
389-
winnerId: game.winnerId,
390-
chat: game.chat as unknown as InputJsonValue[],
391-
timers: game.timers as unknown as InputJsonValue,
392-
timeControl: game.timeControl as unknown as InputJsonValue,
393-
},
394-
});
395-
396-
await tx.room.update({
397-
where: { id: game.roomId },
398-
data: { status: RoomStatus.CLOSED },
399-
});
400-
}),
401-
{
402-
maxWait: 10000,
403-
timeout: 20000,
404-
};
506+
await this._persistGameEnd(game);
405507
}
406508

407509
await redis.setJSON(`game:${gameId}`, game);
408510

409511
this.ws.broadcastToGame(game);
512+
513+
const botPlayer = game.players.find((p) => p.name === "Computer");
514+
if (
515+
botPlayer &&
516+
game.status === GameStatus.ACTIVE &&
517+
chess.turn() === botPlayer.color[0]
518+
) {
519+
const thinkingTime = Math.floor(Math.random() * 8000) + 2000;
520+
setTimeout(() => {
521+
this.makeBotMove(gameId);
522+
}, thinkingTime);
523+
}
524+
}
525+
526+
async makeBotMove(gameId: string): Promise<void> {
527+
const gameData = await redis.get(`game:${gameId}`);
528+
if (!gameData) return;
529+
530+
const game = JSON.parse(gameData) as Game;
531+
if (game.status !== GameStatus.ACTIVE) return;
532+
533+
const chess = new Chess(game.fen);
534+
const botPlayer = game.players.find((p) => p.name === "Computer");
535+
536+
if (!botPlayer || chess.turn() !== botPlayer.color[0]) {
537+
return;
538+
}
539+
540+
const botMove = findBestMove(chess, 1);
541+
542+
if (botMove) {
543+
const botMoveResult = chess.move(botMove.san);
544+
545+
if (botMoveResult) {
546+
game.fen = chess.fen();
547+
game.moveHistory.push({
548+
from: botMove.from,
549+
to: botMove.to,
550+
san: botMove.san,
551+
});
552+
553+
if (game.timeControl.increment > 0) {
554+
const botColor = botPlayer.color as "white" | "black";
555+
game.timers[botColor] += game.timeControl.increment;
556+
}
557+
558+
if (chess.isCheckmate() || chess.isDraw()) {
559+
game.status = chess.isDraw() ? GameStatus.DRAW : GameStatus.COMPLETED;
560+
if (chess.isCheckmate()) game.winnerId = botPlayer.userId;
561+
await this._persistGameEnd(game);
562+
}
563+
564+
await redis.setJSON(`game:${gameId}`, game);
565+
this.ws.broadcastToGame(game);
566+
}
567+
}
410568
}
411569

412570
async getLegalMoves(

backend/src/games/services/room.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -452,7 +452,7 @@ export class RoomService {
452452
players: dbGame.players.map((p) => ({
453453
userId: p.userId,
454454
color: p.color,
455-
name: p.user.name,
455+
name: p.user.name!,
456456
})),
457457
chat: dbGame.chat as unknown as ChatMessage[],
458458
winnerId: dbGame.winnerId || undefined,

backend/src/lib/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ export interface Game {
7474
timers: { white: number; black: number };
7575
timeControl: TimeControl;
7676
status: GameStatus;
77-
players: { userId: string; color: string }[];
77+
players: { userId: string; color: string; name?: string }[];
7878
chat: ChatMessage[];
7979
winnerId?: string;
8080
createdAt: Date;

0 commit comments

Comments
 (0)