diff --git a/next.config.ts b/next.config.ts index 6a646a07..829136f8 100644 --- a/next.config.ts +++ b/next.config.ts @@ -60,6 +60,19 @@ const nextConfig = (phase: string): NextConfig => ({ }, ], }, + { + source: "/playground", + headers: [ + { + key: "Cross-Origin-Embedder-Policy", + value: "require-corp", + }, + { + key: "Cross-Origin-Opener-Policy", + value: "same-origin", + }, + ], + }, { source: "/database", headers: [ diff --git a/src/pages/playground.tsx b/src/pages/playground.tsx new file mode 100644 index 00000000..db574d92 --- /dev/null +++ b/src/pages/playground.tsx @@ -0,0 +1,85 @@ +import { PageTitle } from "@/components/pageTitle"; +import { useAtomLocalStorage } from "@/hooks/useAtomLocalStorage"; +import { useEngine } from "@/hooks/useEngine"; +import { Stockfish16_1 } from "@/lib/engine/stockfish16_1"; +import { isEngineSupported } from "@/lib/engine/shared"; +import PlaygroundBoard from "@/sections/playground/board"; +import PlaygroundEngineLines from "@/sections/playground/engineLines"; +import { usePlaygroundCurrentPosition } from "@/sections/playground/hooks/useCurrentPosition"; +import PlaygroundToolbar from "@/sections/playground/toolbar"; +import { engineNameAtom } from "@/sections/analysis/states"; +import { DEFAULT_ENGINE, ENGINE_LABELS } from "@/constants"; +import { EngineName } from "@/types/enums"; +import { Grid2 as Grid, Typography } from "@mui/material"; +import { useEffect } from "react"; + +export default function Playground() { + const [engineName, setEngineName] = useAtomLocalStorage( + "engine-name", + engineNameAtom + ); + const engine = useEngine(engineName); + + useEffect(() => { + if (!isEngineSupported(engineName)) { + if (Stockfish16_1.isSupported()) { + setEngineName(EngineName.Stockfish16_1Lite); + } else { + setEngineName(EngineName.Stockfish11); + } + } + }, [engineName, setEngineName]); + + usePlaygroundCurrentPosition(engine); + + return ( + + + + + + + + + Playground + + + Free play with live analysis from{" "} + {ENGINE_LABELS[engineName ?? DEFAULT_ENGINE].small} + + + + + + + + + + + + + ); +} diff --git a/src/sections/layout/NavMenu.tsx b/src/sections/layout/NavMenu.tsx index 337b2299..4d06ac2f 100644 --- a/src/sections/layout/NavMenu.tsx +++ b/src/sections/layout/NavMenu.tsx @@ -13,6 +13,11 @@ import { const MenuOptions = [ { text: "Play", icon: "streamline:chess-pawn", href: "/play" }, + { + text: "Playground", + icon: "mdi:flask-outline", + href: "/playground", + }, { text: "Analysis", icon: "streamline:magnifying-glass-solid", href: "/" }, { text: "Database", diff --git a/src/sections/playground/board.tsx b/src/sections/playground/board.tsx new file mode 100644 index 00000000..48d0e2c1 --- /dev/null +++ b/src/sections/playground/board.tsx @@ -0,0 +1,43 @@ +import Board from "@/components/board"; +import { usePlayersData } from "@/hooks/usePlayersData"; +import { useScreenSize } from "@/hooks/useScreenSize"; +import { showPlayerMoveIconAtom } from "@/sections/analysis/states"; +import { useAtomValue } from "jotai"; +import { useMemo } from "react"; +import { + playgroundBoardOrientationAtom, + playgroundCurrentPositionAtom, + playgroundGameAtom, +} from "./states"; + +export default function PlaygroundBoard() { + const screenSize = useScreenSize(); + const boardOrientation = useAtomValue(playgroundBoardOrientationAtom); + const { white, black } = usePlayersData(playgroundGameAtom); + + const boardSize = useMemo(() => { + const width = screenSize.width; + const height = screenSize.height; + + if (window?.innerWidth < 1200) { + return Math.min(width, height - 150); + } + + return Math.min(width - 700, height * 0.92); + }, [screenSize]); + + return ( + + ); +} diff --git a/src/sections/playground/engineLines.tsx b/src/sections/playground/engineLines.tsx new file mode 100644 index 00000000..b9f1988f --- /dev/null +++ b/src/sections/playground/engineLines.tsx @@ -0,0 +1,123 @@ +import PrettyMoveSan from "@/components/prettyMoveSan"; +import { useChessActions } from "@/hooks/useChessActions"; +import { getLineEvalLabel, moveLineUciToSan } from "@/lib/chess"; +import { LineEval } from "@/types/eval"; +import { List, ListItem, Skeleton, Stack, Typography } from "@mui/material"; +import { useAtomValue } from "jotai"; +import { playgroundCurrentPositionAtom, playgroundGameAtom } from "./states"; + +const linesSkeleton: LineEval[] = Array.from({ length: 3 }).map((_, i) => ({ + pv: [`${i}`], + depth: 0, + multiPv: i + 1, +})); + +export default function PlaygroundEngineLines() { + const game = useAtomValue(playgroundGameAtom); + const position = useAtomValue(playgroundCurrentPositionAtom); + const { addMoves } = useChessActions(playgroundGameAtom); + + const lines = position.eval?.lines?.length + ? position.eval.lines.slice(0, 3) + : linesSkeleton; + const uciToSan = moveLineUciToSan(game.fen()); + const turn = game.turn(); + + const getColorFromMoveIdx = (moveIdx: number): "w" | "b" => { + if (moveIdx % 2 === 0) return turn; + return turn === "w" ? "b" : "w"; + }; + + return ( + + + Stockfish top lines + {position.opening && ( + + {position.opening} + + )} + + + {game.isGameOver() && ( + + Game is over + + )} + + + {lines.map((line) => { + const showSkeleton = line.depth < 6; + const isBlackEval = + (line.cp !== undefined && line.cp < 0) || + (line.mate !== undefined && line.mate < 0); + + return ( + + + {showSkeleton ? ( + + placeholder + + ) : ( + getLineEvalLabel(line) + )} + + + + {showSkeleton ? ( + + ) : ( + line.pv.map((uci, i) => ( + addMoves(line.pv.slice(0, i + 1)), + sx: { + cursor: "pointer", + ml: i ? 0.5 : 0, + transition: "opacity 0.2s ease-in-out", + "&:hover": { + opacity: 0.5, + }, + }, + }} + /> + )) + )} + + + ); + })} + + + ); +} diff --git a/src/sections/playground/hooks/useCurrentPosition.ts b/src/sections/playground/hooks/useCurrentPosition.ts new file mode 100644 index 00000000..5f94f230 --- /dev/null +++ b/src/sections/playground/hooks/useCurrentPosition.ts @@ -0,0 +1,173 @@ +import { openings } from "@/data/openings"; +import { getEvaluateGameParams } from "@/lib/chess"; +import { getMovesClassification } from "@/lib/engine/helpers/moveClassification"; +import { UciEngine } from "@/lib/engine/uciEngine"; +import { CurrentPosition, PositionEval } from "@/types/eval"; +import { useAtom, useAtomValue } from "jotai"; +import { useEffect, useRef } from "react"; +import { + playgroundCurrentPositionAtom, + playgroundGameAtom, + playgroundSavedEvalsAtom, +} from "../states"; + +const PLAYGROUND_MULTI_PV = 3; +const PLAYGROUND_DEPTH = 14; + +export const usePlaygroundCurrentPosition = (engine: UciEngine | null) => { + const [currentPosition, setCurrentPosition] = useAtom( + playgroundCurrentPositionAtom + ); + const game = useAtomValue(playgroundGameAtom); + const [savedEvals, setSavedEvals] = useAtom(playgroundSavedEvalsAtom); + const savedEvalsRef = useRef(savedEvals); + const inFlightEvalsRef = useRef>>({}); + + useEffect(() => { + savedEvalsRef.current = savedEvals; + }, [savedEvals]); + + useEffect(() => { + const history = game.history({ verbose: true }); + const gameFen = game.fen(); + let isCancelled = false; + + const position: CurrentPosition = { + lastMove: history.at(-1), + currentMoveIdx: history.length, + }; + + for (const move of history.slice().reverse()) { + const moveFen = move.after.split(" ")[0]; + const opening = openings.find((opening) => opening.fen === moveFen); + if (opening) { + position.opening = opening.name; + break; + } + } + + setCurrentPosition(position); + + if (!engine?.getIsReady() || !engine.name || game.isGameOver()) { + return; + } + + const getFenEval = async ( + fen: string, + setPartialEval?: (positionEval: PositionEval) => void + ) => { + if (!engine.getIsReady()) { + throw new Error("Engine not ready"); + } + + const savedEval = savedEvalsRef.current[fen]; + if ( + savedEval && + savedEval.engine === engine.name && + (savedEval.lines?.length ?? 0) >= PLAYGROUND_MULTI_PV && + (savedEval.lines[0]?.depth ?? 0) >= PLAYGROUND_DEPTH + ) { + const positionEval: PositionEval = { + ...savedEval, + lines: savedEval.lines.slice(0, PLAYGROUND_MULTI_PV), + }; + setPartialEval?.(positionEval); + return positionEval; + } + + const requestKey = [ + engine.name, + fen, + PLAYGROUND_DEPTH, + PLAYGROUND_MULTI_PV, + ].join(":"); + + if (!inFlightEvalsRef.current[requestKey]) { + inFlightEvalsRef.current[requestKey] = engine + .evaluatePositionWithUpdate({ + fen, + depth: PLAYGROUND_DEPTH, + multiPv: PLAYGROUND_MULTI_PV, + setPartialEval, + }) + .finally(() => { + delete inFlightEvalsRef.current[requestKey]; + }); + } + + const rawPositionEval = await inFlightEvalsRef.current[requestKey]; + + if (rawPositionEval.lines.length === 0) { + return rawPositionEval; + } + + setSavedEvals((prev) => ({ + ...prev, + [fen]: { ...rawPositionEval, engine: engine.name }, + })); + + return rawPositionEval; + }; + + const getPositionEval = async () => { + const setPartialEval = (positionEval: PositionEval) => { + if (isCancelled || gameFen !== game.fen()) return; + setCurrentPosition((prev) => ({ ...prev, eval: positionEval })); + }; + + const currentEval = await getFenEval(game.fen(), setPartialEval); + if (isCancelled || gameFen !== game.fen()) return; + + if (!history.length) return; + + const params = getEvaluateGameParams(game); + const fens = params.fens.slice(game.turn() === "w" ? -3 : -4); + const uciMoves = params.uciMoves.slice(game.turn() === "w" ? -2 : -3); + + const previousFen = fens.slice(-2)[0]; + if (!previousFen) return; + + const previousEval = await getFenEval(previousFen); + if (isCancelled || gameFen !== game.fen()) return; + + const rawPositions: PositionEval[] = fens.map((_, idx) => { + if (idx === fens.length - 2) return previousEval; + if (idx === fens.length - 1) return currentEval; + + return { + lines: [ + { + pv: [], + depth: 0, + multiPv: 1, + cp: 1, + }, + ], + }; + }); + + const positionsWithMoveClassification = getMovesClassification( + rawPositions, + uciMoves, + fens + ); + + setCurrentPosition((prev) => ({ + ...prev, + eval: positionsWithMoveClassification.slice(-1)[0], + lastEval: positionsWithMoveClassification.slice(-2)[0], + })); + }; + + getPositionEval(); + + return () => { + isCancelled = true; + if (engine.getIsReady()) { + engine.stopAllCurrentJobs(); + } + }; + }, [engine, game, setCurrentPosition, setSavedEvals]); + + return currentPosition; +}; diff --git a/src/sections/playground/newPlaygroundDialog.tsx b/src/sections/playground/newPlaygroundDialog.tsx new file mode 100644 index 00000000..942f71af --- /dev/null +++ b/src/sections/playground/newPlaygroundDialog.tsx @@ -0,0 +1,90 @@ +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + TextField, + Typography, +} from "@mui/material"; +import { useState } from "react"; +import { useChessActions } from "@/hooks/useChessActions"; +import { getGameFromPgn } from "@/lib/chess"; +import { playgroundGameAtom } from "./states"; + +interface Props { + open: boolean; + onClose: () => void; +} + +export default function NewPlaygroundDialog({ open, onClose }: Props) { + const { reset } = useChessActions(playgroundGameAtom); + const [startingPositionInput, setStartingPositionInput] = useState(""); + const [parsingError, setParsingError] = useState(""); + + const handleClose = () => { + onClose(); + setStartingPositionInput(""); + setParsingError(""); + }; + + const handleStart = () => { + setParsingError(""); + + try { + const input = startingPositionInput.trim(); + const startingFen = input.startsWith("[") + ? getGameFromPgn(input).fen() + : input || undefined; + + reset({ + fen: startingFen, + white: { name: "White" }, + black: { name: "Black" }, + }); + handleClose(); + } catch (error) { + console.error(error); + setParsingError( + error instanceof Error + ? `${error.message} !` + : "Unknown error while parsing input !" + ); + } + }; + + return ( + + + Start a new playground + + + setStartingPositionInput(e.target.value)} + /> + + {parsingError && ( + + {parsingError} + + )} + + + + + + + ); +} diff --git a/src/sections/playground/states.ts b/src/sections/playground/states.ts new file mode 100644 index 00000000..5e940c0c --- /dev/null +++ b/src/sections/playground/states.ts @@ -0,0 +1,16 @@ +import { CurrentPosition, SavedEvals } from "@/types/eval"; +import { Color } from "@/types/enums"; +import { setGameHeaders } from "@/lib/chess"; +import { Chess } from "chess.js"; +import { atom } from "jotai"; + +export const createPlaygroundGame = (fen?: string) => + setGameHeaders(new Chess(fen), { + white: { name: "White" }, + black: { name: "Black" }, + }); + +export const playgroundGameAtom = atom(createPlaygroundGame()); +export const playgroundCurrentPositionAtom = atom({}); +export const playgroundSavedEvalsAtom = atom({}); +export const playgroundBoardOrientationAtom = atom(Color.White); diff --git a/src/sections/playground/toolbar.tsx b/src/sections/playground/toolbar.tsx new file mode 100644 index 00000000..2e95f2c9 --- /dev/null +++ b/src/sections/playground/toolbar.tsx @@ -0,0 +1,88 @@ +import { useChessActions } from "@/hooks/useChessActions"; +import { setGameHeaders } from "@/lib/chess"; +import { Color } from "@/types/enums"; +import { Icon } from "@iconify/react"; +import { Button, Grid2 as Grid, IconButton, Tooltip } from "@mui/material"; +import { useAtom, useAtomValue } from "jotai"; +import { Chess } from "chess.js"; +import { useState } from "react"; +import NewPlaygroundDialog from "./newPlaygroundDialog"; +import { playgroundBoardOrientationAtom, playgroundGameAtom } from "./states"; + +export default function PlaygroundToolbar() { + const game = useAtomValue(playgroundGameAtom); + const { undoMove } = useChessActions(playgroundGameAtom); + const [boardOrientation, setBoardOrientation] = useAtom( + playgroundBoardOrientationAtom + ); + const [openDialog, setOpenDialog] = useState(false); + + const handleCopyPgn = () => { + if (!game.history().length) return; + + const exportGame = new Chess(); + exportGame.loadPgn(game.pgn()); + setGameHeaders(exportGame); + navigator.clipboard?.writeText?.(exportGame.pgn()); + }; + + return ( + <> + + + + + + + + + undoMove()} + disabled={game.history().length === 0} + sx={{ paddingX: 1.2, paddingY: 0.5 }} + > + + + + + + + + + + setBoardOrientation( + boardOrientation === Color.White ? Color.Black : Color.White + ) + } + sx={{ paddingX: 1.2, paddingY: 0.5 }} + > + + + + + + + + + + + + + + + + + setOpenDialog(false)} + /> + + ); +}