diff --git a/firebase.json b/firebase.json index 4082605..6583d11 100644 --- a/firebase.json +++ b/firebase.json @@ -32,6 +32,19 @@ "value": "same-origin" } ] + }, + { + "source": "/play", + "headers": [ + { + "key": "Cross-Origin-Embedder-Policy", + "value": "require-corp" + }, + { + "key": "Cross-Origin-Opener-Policy", + "value": "same-origin" + } + ] } ] } diff --git a/next.config.js b/next.config.js index f3f5b8e..64106d3 100644 --- a/next.config.js +++ b/next.config.js @@ -38,6 +38,19 @@ const nextConfig = (phase) => }, ], }, + { + source: "/play", + headers: [ + { + key: "Cross-Origin-Embedder-Policy", + value: "require-corp", + }, + { + key: "Cross-Origin-Opener-Policy", + value: "same-origin", + }, + ], + }, ], }); diff --git a/src/lib/engine/helpers/parseResults.ts b/src/lib/engine/helpers/parseResults.ts index abd3ead..d2eee90 100644 --- a/src/lib/engine/helpers/parseResults.ts +++ b/src/lib/engine/helpers/parseResults.ts @@ -72,7 +72,7 @@ export const sortLines = (a: LineEval, b: LineEval): number => { return (b.cp ?? 0) - (a.cp ?? 0); }; -const getResultProperty = ( +export const getResultProperty = ( result: string, property: string ): string | undefined => { diff --git a/src/lib/engine/uciEngine.ts b/src/lib/engine/uciEngine.ts index d55e085..0da3c3b 100644 --- a/src/lib/engine/uciEngine.ts +++ b/src/lib/engine/uciEngine.ts @@ -5,7 +5,10 @@ import { GameEval, PositionEval, } from "@/types/eval"; -import { parseEvaluationResults } from "./helpers/parseResults"; +import { + getResultProperty, + parseEvaluationResults, +} from "./helpers/parseResults"; import { computeAccuracy } from "./helpers/accuracy"; import { getWhoIsCheckmated } from "../chess"; import { getLichessEval } from "../lichess"; @@ -16,6 +19,7 @@ export abstract class UciEngine { private ready = false; private engineName: EngineName; private multiPv = 3; + private skillLevel: number | undefined = undefined; constructor(engineName: EngineName, enginePath: string) { this.engineName = engineName; @@ -50,6 +54,25 @@ export abstract class UciEngine { this.multiPv = multiPv; } + private async setSkillLevel(skillLevel: number, initCase = false) { + if (!initCase) { + if (skillLevel === this.skillLevel) return; + + this.throwErrorIfNotReady(); + } + + if (skillLevel < 0 || skillLevel > 20) { + throw new Error(`Invalid SkillLevel value : ${skillLevel}`); + } + + await this.sendCommands( + [`setoption name Skill Level value ${skillLevel}`, "isready"], + "readyok" + ); + + this.skillLevel = skillLevel; + } + private throwErrorIfNotReady() { if (!this.ready) { throw new Error(`${this.engineName} is not ready`); @@ -215,4 +238,28 @@ export abstract class UciEngine { onNewMessage ); } + + public async getEngineNextMove( + fen: string, + skillLevel: number, + depth = 16 + ): Promise { + this.throwErrorIfNotReady(); + await this.setSkillLevel(skillLevel); + + console.log(`Evaluating position: ${fen}`); + + const results = await this.sendCommands( + [`position fen ${fen}`, `go depth ${depth}`], + "bestmove" + ); + + const moveResult = results.find((result) => result.startsWith("bestmove")); + const move = getResultProperty(moveResult ?? "", "bestmove"); + if (!move) { + throw new Error("No move found"); + } + + return move; + } } diff --git a/src/pages/play.tsx b/src/pages/play.tsx new file mode 100644 index 0000000..d399eec --- /dev/null +++ b/src/pages/play.tsx @@ -0,0 +1,48 @@ +import Board from "@/sections/play/board"; +import { CircularProgress, Divider, Grid, Typography } from "@mui/material"; + +export default function Play() { + return ( + + + + + + Game in progress + + + + + + + ); +} diff --git a/src/sections/layout/NavMenu.tsx b/src/sections/layout/NavMenu.tsx index 75f9f4d..3f05b33 100644 --- a/src/sections/layout/NavMenu.tsx +++ b/src/sections/layout/NavMenu.tsx @@ -12,10 +12,11 @@ import { } from "@mui/material"; const MenuOptions = [ + { text: "Play", icon: "streamline:chess-pawn", href: "/play" }, { text: "Analysis", icon: "streamline:magnifying-glass-solid", href: "/" }, { text: "Database", - icon: "streamline:database-solid", + icon: "streamline:database", href: "/database", }, ]; diff --git a/src/sections/play/board/index.tsx b/src/sections/play/board/index.tsx new file mode 100644 index 0000000..15a6bfc --- /dev/null +++ b/src/sections/play/board/index.tsx @@ -0,0 +1,112 @@ +import { Grid } from "@mui/material"; +import { Chessboard } from "react-chessboard"; +import { useAtomValue, useSetAtom } from "jotai"; +import { + clickedSquaresAtom, + engineSkillLevelAtom, + gameAtom, + playerColorAtom, +} from "../states"; +import { Square } from "react-chessboard/dist/chessboard/types"; +import { useChessActions } from "@/hooks/useChessActions"; +import { useEffect, useRef } from "react"; +import PlayerInfo from "./playerInfo"; +import { useScreenSize } from "@/hooks/useScreenSize"; +import { Color, EngineName } from "@/types/enums"; +import SquareRenderer from "./squareRenderer"; +import { useEngine } from "@/hooks/useEngine"; +import { uciMoveParams } from "@/lib/chess"; + +export default function Board() { + const boardRef = useRef(null); + const { boardSize } = useScreenSize(); + const game = useAtomValue(gameAtom); + const playerColor = useAtomValue(playerColorAtom); + const { makeMove: makeBoardMove } = useChessActions(gameAtom); + const setClickedSquares = useSetAtom(clickedSquaresAtom); + const engineSkillLevel = useAtomValue(engineSkillLevelAtom); + const engine = useEngine(EngineName.Stockfish16); + + const gameFen = game.fen(); + const turn = game.turn(); + + useEffect(() => { + const playEngineMove = async () => { + if (!engine?.isReady() || turn === playerColor) return; + const move = await engine.getEngineNextMove( + gameFen, + engineSkillLevel - 1 + ); + makeBoardMove(uciMoveParams(move)); + }; + playEngineMove(); + }, [engine, turn, engine, playerColor, engineSkillLevel]); + + useEffect(() => { + setClickedSquares([]); + }, [gameFen, setClickedSquares]); + + const onPieceDrop = ( + source: Square, + target: Square, + piece: string + ): boolean => { + if (!piece || piece[0] !== playerColor) return false; + try { + const result = makeBoardMove({ + from: source, + to: target, + promotion: piece[1]?.toLowerCase() ?? "q", + }); + + return !!result; + } catch { + return false; + } + }; + + const isPieceDraggable = ({ piece }: { piece: string }): boolean => { + if (!piece) return false; + return playerColor === piece[0]; + }; + + return ( + + + + + + + + + + ); +} diff --git a/src/sections/play/board/playerInfo.tsx b/src/sections/play/board/playerInfo.tsx new file mode 100644 index 0000000..98de1a4 --- /dev/null +++ b/src/sections/play/board/playerInfo.tsx @@ -0,0 +1,20 @@ +import { Grid, Typography } from "@mui/material"; +import { useAtomValue } from "jotai"; +import { playerColorAtom } from "../states"; +import { Color } from "@/types/enums"; + +interface Props { + color: Color; +} + +export default function PlayerInfo({ color }: Props) { + const playerColor = useAtomValue(playerColorAtom); + + const playerName = playerColor === color ? "You 🧠" : "Stockfish 🤖"; + + return ( + + {playerName} + + ); +} diff --git a/src/sections/play/board/squareRenderer.tsx b/src/sections/play/board/squareRenderer.tsx new file mode 100644 index 0000000..e83e458 --- /dev/null +++ b/src/sections/play/board/squareRenderer.tsx @@ -0,0 +1,78 @@ +import { clickedSquaresAtom, gameAtom } from "../states"; +import { atom, useAtom, useAtomValue } from "jotai"; +import { CSSProperties, MouseEventHandler, forwardRef } from "react"; +import { CustomSquareProps } from "react-chessboard/dist/chessboard/types"; + +const rightClickEventSquareAtom = atom(null); + +const SquareRenderer = forwardRef( + (props, ref) => { + const { children, square, style } = props; + const game = useAtomValue(gameAtom); + const [clickedSquares, setClickedSquares] = useAtom(clickedSquaresAtom); + const [rightClickEventSquare, setRightClickEventSquare] = useAtom( + rightClickEventSquareAtom + ); + + const lastMove = game.history({ verbose: true }).at(-1); + const fromSquare = lastMove?.from; + const toSquare = lastMove?.to; + + const customSquareStyle: CSSProperties | undefined = + clickedSquares.includes(square) + ? { + position: "absolute", + width: "100%", + height: "100%", + backgroundColor: "#eb6150", + opacity: "0.8", + } + : fromSquare === square || toSquare === square + ? { + position: "absolute", + width: "100%", + height: "100%", + backgroundColor: "#fad541", + opacity: 0.5, + } + : undefined; + + const handleSquareLeftClick: MouseEventHandler = () => { + setClickedSquares([]); + }; + + const handleSquareRightClick: MouseEventHandler = ( + event + ) => { + if (event.button !== 2) return; + + if (rightClickEventSquare !== square) { + setRightClickEventSquare(null); + return; + } + + setClickedSquares((prev) => + prev.includes(square) + ? prev.filter((s) => s !== square) + : [...prev, square] + ); + }; + + return ( +
e.button === 2 && setRightClickEventSquare(square)} + onMouseUp={handleSquareRightClick} + > + {children} + {customSquareStyle &&
} +
+ ); + } +); + +SquareRenderer.displayName = "CustomSquareRenderer"; + +export default SquareRenderer; diff --git a/src/sections/play/states.ts b/src/sections/play/states.ts new file mode 100644 index 0000000..6c212e9 --- /dev/null +++ b/src/sections/play/states.ts @@ -0,0 +1,9 @@ +import { Color } from "@/types/enums"; +import { Chess } from "chess.js"; +import { atom } from "jotai"; + +export const gameAtom = atom(new Chess()); +export const playerColorAtom = atom(Color.White); +export const engineSkillLevelAtom = atom(1); + +export const clickedSquaresAtom = atom([]); diff --git a/src/types/enums.ts b/src/types/enums.ts index 2bc2a99..dee0780 100644 --- a/src/types/enums.ts +++ b/src/types/enums.ts @@ -19,3 +19,8 @@ export enum MoveClassification { Great = "great", Brilliant = "brilliant", } + +export enum Color { + White = "w", + Black = "b", +}