Squashed commit of the following:

commit d9209a78cff1c05be3e6a87e27cd1a5a4d5f91c5
Author: GuillaumeSD <47183782+GuillaumeSD@users.noreply.github.com>
Date:   Wed Jul 24 11:55:35 2024 +0200

    style : UI analysis panel adjustment

commit 3c2e19bdb9d97f3bb7e8ceaefd630aad64d755c4
Author: GuillaumeSD <47183782+GuillaumeSD@users.noreply.github.com>
Date:   Wed Jul 24 11:10:07 2024 +0200

    feat : graph dot color match move classification

commit 4a99ccb2fe19d3806ff320370ebc55af984d719a
Author: GuillaumeSD <47183782+GuillaumeSD@users.noreply.github.com>
Date:   Wed Jul 24 11:09:35 2024 +0200

    fix : load pgn with no moves

commit 9eeb0e7f2869e544700b7da963b74f707fa6ea2f
Author: GuillaumeSD <47183782+GuillaumeSD@users.noreply.github.com>
Date:   Wed Jul 24 00:09:03 2024 +0200

    feat : add current move reference line in graph

commit febb9962a0b366aeac1dc266e0470b75bd619e68
Author: GuillaumeSD <47183782+GuillaumeSD@users.noreply.github.com>
Date:   Tue Jul 23 23:08:17 2024 +0200

    fix : handle tab change on new game

commit a105239a728dc05211a0ae99d8fd56f179108a0e
Author: GuillaumeSD <47183782+GuillaumeSD@users.noreply.github.com>
Date:   Tue Jul 23 03:46:49 2024 +0200

    style : small chart UI tweaks

commit 4878ebf87b4ddbac75db70619fe452a3a317ca09
Author: GuillaumeSD <47183782+GuillaumeSD@users.noreply.github.com>
Date:   Tue Jul 23 03:38:40 2024 +0200

    feat : add eval graph

commit 29c5a001da03ee288d2a2c133426b1d2ca435930
Author: GuillaumeSD <47183782+GuillaumeSD@users.noreply.github.com>
Date:   Tue Jul 23 00:30:25 2024 +0200

    refacto : analysis directory

commit a8b966cc07152bb117b8c68f54af3498ca2a5d2f
Author: GuillaumeSD <47183782+GuillaumeSD@users.noreply.github.com>
Date:   Tue Jul 23 00:07:07 2024 +0200

    style : add settings floating button

commit 7edc54f09ce7d4b4c4beb310a9c7f985363ff5ee
Author: GuillaumeSD <47183782+GuillaumeSD@users.noreply.github.com>
Date:   Sun Jul 21 22:29:48 2024 +0200

    feat : tab analysis panel
This commit is contained in:
GuillaumeSD
2024-07-24 11:58:42 +02:00
parent 9d5b088ae9
commit 2baf9b76ad
35 changed files with 754 additions and 156 deletions

View File

@@ -0,0 +1,46 @@
import { Grid, Typography } from "@mui/material";
import { useAtomValue } from "jotai";
import { gameEvalAtom } from "../../states";
export default function Accuracies() {
const gameEval = useAtomValue(gameEvalAtom);
if (!gameEval) return null;
return (
<Grid
item
container
xs={12}
justifyContent="center"
alignItems="center"
columnGap={{ xs: "8vw", md: 10 }}
>
<Typography
align="center"
sx={{ backgroundColor: "white", color: "black" }}
borderRadius="5px"
lineHeight={1}
padding={1}
fontWeight="bold"
border="1px solid #424242"
>
{`${gameEval?.accuracy.white.toFixed(1)} %`}
</Typography>
<Typography align="center">Accuracies</Typography>
<Typography
align="center"
sx={{ backgroundColor: "black", color: "white" }}
borderRadius="5px"
lineHeight={1}
padding={1}
fontWeight="bold"
border="1px solid #424242"
>
{`${gameEval?.accuracy.black.toFixed(1)} %`}
</Typography>
</Grid>
);
}

View File

@@ -0,0 +1,80 @@
import { Grid, GridProps, List, Typography } from "@mui/material";
import { useAtomValue } from "jotai";
import {
boardAtom,
engineMultiPvAtom,
engineNameAtom,
gameAtom,
} from "../../states";
import LineEvaluation from "./lineEvaluation";
import { useCurrentPosition } from "../../hooks/useCurrentPosition";
import { LineEval } from "@/types/eval";
import Accuracies from "./accuracies";
import MoveInfo from "./moveInfo";
import Opening from "./opening";
export default function AnalysisTab(props: GridProps) {
const linesNumber = useAtomValue(engineMultiPvAtom);
const engineName = useAtomValue(engineNameAtom);
const position = useCurrentPosition(engineName);
const game = useAtomValue(gameAtom);
const board = useAtomValue(boardAtom);
const boardHistory = board.history();
const gameHistory = game.history();
const isGameOver =
boardHistory.length > 0 &&
(board.isCheckmate() ||
board.isDraw() ||
boardHistory.join() === gameHistory.join());
const linesSkeleton: LineEval[] = Array.from({ length: linesNumber }).map(
(_, i) => ({ pv: [`${i}`], depth: 0, multiPv: i + 1 })
);
const engineLines = position?.eval?.lines?.length
? position.eval.lines
: linesSkeleton;
return (
<Grid
item
container
xs={12}
justifyContent="center"
alignItems="start"
height="100%"
rowGap={1.2}
{...props}
sx={
props.hidden
? { display: "none" }
: { overflow: "hidden", overflowY: "auto", ...props.sx }
}
>
<Accuracies />
<MoveInfo />
<Opening />
{isGameOver && (
<Grid item xs={12}>
<Typography align="center" fontSize="0.9rem">
Game is over
</Typography>
</Grid>
)}
<Grid item container xs={12} justifyContent="center" alignItems="center">
<List sx={{ maxWidth: "95%", padding: 0 }}>
{!board.isCheckmate() &&
engineLines.map((line) => (
<LineEvaluation key={line.multiPv} line={line} />
))}
</List>
</Grid>
</Grid>
);
}

View File

@@ -0,0 +1,66 @@
import { LineEval } from "@/types/eval";
import { ListItem, Skeleton, Typography } from "@mui/material";
import { useAtomValue } from "jotai";
import { boardAtom } from "../../states";
import { getLineEvalLabel, moveLineUciToSan } from "@/lib/chess";
interface Props {
line: LineEval;
}
export default function LineEvaluation({ line }: Props) {
const board = useAtomValue(boardAtom);
const lineLabel = getLineEvalLabel(line);
const isBlackCp =
(line.cp !== undefined && line.cp < 0) ||
(line.mate !== undefined && line.mate < 0);
const showSkeleton = line.depth < 6;
return (
<ListItem disablePadding>
<Typography
marginRight={1.5}
marginY={0.5}
paddingY={0.2}
noWrap
overflow="visible"
width="3.5em"
textAlign="center"
fontSize="0.8rem"
sx={{
backgroundColor: isBlackCp ? "black" : "white",
color: isBlackCp ? "white" : "black",
}}
borderRadius="5px"
border="1px solid #424242"
fontWeight="bold"
>
{showSkeleton ? (
<Skeleton
variant="rounded"
animation="wave"
sx={{ color: "transparent" }}
>
placeholder
</Skeleton>
) : (
lineLabel
)}
</Typography>
<Typography
noWrap
maxWidth={{ xs: "12em", sm: "25em", md: "30em", lg: "25em" }}
fontSize="0.9rem"
>
{showSkeleton ? (
<Skeleton variant="rounded" animation="wave" width="15em" />
) : (
line.pv.map(moveLineUciToSan(board.fen())).join(", ")
)}
</Typography>
</ListItem>
);
}

View File

@@ -0,0 +1,64 @@
import { Grid, Typography } from "@mui/material";
import { useAtomValue } from "jotai";
import { boardAtom, currentPositionAtom } from "../../states";
import { useMemo } from "react";
import { moveLineUciToSan } from "@/lib/chess";
import { MoveClassification } from "@/types/enums";
export default function MoveInfo() {
const position = useAtomValue(currentPositionAtom);
const board = useAtomValue(boardAtom);
const bestMove = position?.lastEval?.bestMove;
const bestMoveSan = useMemo(() => {
if (!bestMove) return undefined;
const lastPosition = board.history({ verbose: true }).at(-1)?.before;
if (!lastPosition) return undefined;
return moveLineUciToSan(lastPosition)(bestMove);
}, [bestMove, board]);
if (!bestMoveSan) return null;
const moveClassification = position.eval?.moveClassification;
const moveLabel = moveClassification
? `${position.lastMove?.san} is ${moveClassificationLabels[moveClassification]}`
: null;
const bestMoveLabel =
moveClassification === MoveClassification.Best ||
moveClassification === MoveClassification.Book ||
moveClassification === MoveClassification.Brilliant ||
moveClassification === MoveClassification.Great
? null
: `${bestMoveSan} was the best move`;
return (
<Grid item container columnGap={5} xs={12} justifyContent="center">
{moveLabel && (
<Typography align="center" fontSize="0.9rem">
{moveLabel}
</Typography>
)}
{bestMoveLabel && (
<Typography align="center" fontSize="0.9rem">
{bestMoveLabel}
</Typography>
)}
</Grid>
);
}
const moveClassificationLabels: Record<MoveClassification, string> = {
[MoveClassification.Book]: "a book move",
[MoveClassification.Brilliant]: "brilliant !!",
[MoveClassification.Great]: "a great move !",
[MoveClassification.Best]: "the best move",
[MoveClassification.Excellent]: "excellent",
[MoveClassification.Good]: "good",
[MoveClassification.Inaccuracy]: "an inaccuracy",
[MoveClassification.Mistake]: "a mistake",
[MoveClassification.Blunder]: "a blunder",
};

View File

@@ -0,0 +1,17 @@
import { useCurrentPosition } from "../../hooks/useCurrentPosition";
import { Grid, Typography } from "@mui/material";
export default function Opening() {
const position = useCurrentPosition();
const opening = position?.eval?.opening;
if (!opening) return null;
return (
<Grid item xs={12}>
<Typography align="center" fontSize="0.9rem">
{opening}
</Typography>
</Grid>
);
}

View File

@@ -0,0 +1,24 @@
import { Grid, GridProps } from "@mui/material";
import MovesPanel from "./movesPanel";
import MovesClassificationsRecap from "./movesClassificationsRecap";
export default function ClassificationTab(props: GridProps) {
return (
<Grid
container
item
justifyContent="center"
alignItems="start"
height="100%"
maxHeight="18rem"
{...props}
sx={
props.hidden ? { display: "none" } : { overflow: "hidden", ...props.sx }
}
>
<MovesPanel />
<MovesClassificationsRecap />
</Grid>
);
}

View File

@@ -0,0 +1,133 @@
import { Color, MoveClassification } from "@/types/enums";
import { Grid, Typography } from "@mui/material";
import { useAtomValue } from "jotai";
import { boardAtom, gameAtom, gameEvalAtom } from "../../../states";
import { useMemo } from "react";
import Image from "next/image";
import { capitalize } from "@/lib/helpers";
import { useChessActions } from "@/hooks/useChessActions";
import { moveClassificationColors } from "@/lib/chess";
interface Props {
classification: MoveClassification;
}
export default function ClassificationRow({ classification }: Props) {
const gameEval = useAtomValue(gameEvalAtom);
const board = useAtomValue(boardAtom);
const game = useAtomValue(gameAtom);
const { goToMove } = useChessActions(boardAtom);
const whiteNb = useMemo(() => {
if (!gameEval) return 0;
return gameEval.positions.filter(
(position, idx) =>
idx % 2 !== 0 && position.moveClassification === classification
).length;
}, [gameEval, classification]);
const blackNb = useMemo(() => {
if (!gameEval) return 0;
return gameEval.positions.filter(
(position, idx) =>
idx % 2 === 0 && position.moveClassification === classification
).length;
}, [gameEval, classification]);
const handleClick = (color: Color) => {
if (
!gameEval ||
(color === Color.White && !whiteNb) ||
(color === Color.Black && !blackNb)
) {
return;
}
const filterColor = (idx: number) =>
(idx % 2 !== 0 && color === Color.White) ||
(idx % 2 === 0 && color === Color.Black);
const moveIdx = board.history().length;
const nextPositionIdx = gameEval.positions.findIndex(
(position, idx) =>
filterColor(idx) &&
position.moveClassification === classification &&
idx > moveIdx
);
if (nextPositionIdx > 0) {
goToMove(nextPositionIdx, game);
} else {
const firstPositionIdx = gameEval.positions.findIndex(
(position, idx) =>
filterColor(idx) && position.moveClassification === classification
);
if (firstPositionIdx > 0 && firstPositionIdx !== moveIdx) {
goToMove(firstPositionIdx, game);
}
}
};
return (
<Grid
container
item
justifyContent="space-evenly"
alignItems="center"
xs={12}
wrap="nowrap"
color={moveClassificationColors[classification]}
>
<Grid
container
item
justifyContent="center"
alignItems="center"
width={"3rem"}
style={{ cursor: whiteNb ? "pointer" : "default" }}
onClick={() => handleClick(Color.White)}
fontSize="0.9rem"
>
{whiteNb}
</Grid>
<Grid
container
item
justifyContent="start"
alignItems="center"
width={"7rem"}
gap={1}
wrap="nowrap"
>
<Image
src={`/icons/${classification}.png`}
alt="move-icon"
width={18}
height={18}
style={{
maxWidth: "3.5vw",
maxHeight: "3.5vw",
}}
/>
<Typography align="center" fontSize="0.9rem">
{capitalize(classification)}
</Typography>
</Grid>
<Grid
container
item
justifyContent="center"
alignItems="center"
width={"3rem"}
style={{ cursor: blackNb ? "pointer" : "default" }}
onClick={() => handleClick(Color.Black)}
fontSize="0.9rem"
>
{blackNb}
</Grid>
</Grid>
);
}

View File

@@ -0,0 +1,64 @@
import { usePlayersNames } from "@/hooks/usePlayerNames";
import { Grid, Typography } from "@mui/material";
import { gameAtom, gameEvalAtom } from "../../../states";
import { MoveClassification } from "@/types/enums";
import ClassificationRow from "./classificationRow";
import { useAtomValue } from "jotai";
export default function MovesClassificationsRecap() {
const { whiteName, blackName } = usePlayersNames(gameAtom);
const gameEval = useAtomValue(gameEvalAtom);
if (!gameEval?.positions.length) return null;
return (
<Grid
container
item
justifyContent="center"
alignItems="center"
rowGap={1}
xs={6}
sx={{ scrollbarWidth: "thin", overflowY: "auto" }}
maxHeight="100%"
>
<Grid
item
container
alignItems="center"
justifyContent="space-evenly"
wrap="nowrap"
xs={12}
>
<Typography width="12rem" align="center" noWrap fontSize="0.9rem">
{whiteName}
</Typography>
<Typography width="7rem" />
<Typography width="12rem" align="center" noWrap fontSize="0.9rem">
{blackName}
</Typography>
</Grid>
{sortedMoveClassfications.map((classification) => (
<ClassificationRow
key={classification}
classification={classification}
/>
))}
</Grid>
);
}
export const sortedMoveClassfications = [
MoveClassification.Brilliant,
MoveClassification.Great,
MoveClassification.Best,
MoveClassification.Excellent,
MoveClassification.Good,
MoveClassification.Book,
MoveClassification.Inaccuracy,
MoveClassification.Mistake,
MoveClassification.Blunder,
];

View File

@@ -0,0 +1,61 @@
import { Grid } from "@mui/material";
import MovesLine from "./movesLine";
import { useMemo } from "react";
import { useAtomValue } from "jotai";
import { gameAtom, gameEvalAtom } from "../../../states";
import { MoveClassification } from "@/types/enums";
export default function MovesPanel() {
const game = useAtomValue(gameAtom);
const gameEval = useAtomValue(gameEvalAtom);
const gameMoves = useMemo(() => {
const history = game.history();
if (!history.length) return undefined;
const moves: { san: string; moveClassification?: MoveClassification }[][] =
[];
for (let i = 0; i < history.length; i += 2) {
const items = [
{
san: history[i],
moveClassification: gameEval?.positions[i + 1]?.moveClassification,
},
];
if (history[i + 1]) {
items.push({
san: history[i + 1],
moveClassification: gameEval?.positions[i + 2]?.moveClassification,
});
}
moves.push(items);
}
return moves;
}, [game, gameEval]);
return (
<Grid
container
item
justifyContent="center"
alignItems="start"
gap={0.8}
sx={{ scrollbarWidth: "thin", overflowY: "auto" }}
maxHeight="100%"
xs={6}
id="moves-panel"
>
{gameMoves?.map((moves, idx) => (
<MovesLine
key={`${moves.map(({ san }) => san).join()}-${idx}`}
moves={moves}
moveNb={idx + 1}
/>
))}
</Grid>
);
}

View File

@@ -0,0 +1,103 @@
import { MoveClassification } from "@/types/enums";
import { Grid, Typography } from "@mui/material";
import Image from "next/image";
import { useAtomValue } from "jotai";
import { boardAtom, currentPositionAtom, gameAtom } from "../../../states";
import { useChessActions } from "@/hooks/useChessActions";
import { useEffect } from "react";
import { isInViewport } from "@/lib/helpers";
import { moveClassificationColors } from "@/lib/chess";
interface Props {
san: string;
moveClassification?: MoveClassification;
moveIdx: number;
}
export default function MoveItem({ san, moveClassification, moveIdx }: Props) {
const game = useAtomValue(gameAtom);
const { goToMove } = useChessActions(boardAtom);
const position = useAtomValue(currentPositionAtom);
const color = getMoveColor(moveClassification);
const isCurrentMove = position?.currentMoveIdx === moveIdx;
useEffect(() => {
if (!isCurrentMove) return;
const moveItem = document.getElementById(`move-${moveIdx}`);
if (!moveItem) return;
const movePanel = document.getElementById("moves-panel");
if (!movePanel || !isInViewport(movePanel)) return;
moveItem.scrollIntoView({ behavior: "smooth", block: "center" });
}, [isCurrentMove, moveIdx]);
const handleClick = () => {
if (isCurrentMove) return;
goToMove(moveIdx, game);
};
return (
<Grid
item
container
justifyContent="center"
alignItems="center"
gap={1}
width="5rem"
wrap="nowrap"
onClick={handleClick}
paddingY={0.6}
sx={(theme) => ({
cursor: isCurrentMove ? undefined : "pointer",
backgroundColor:
isCurrentMove && theme.palette.mode === "dark"
? "#4f4f4f"
: undefined,
border:
isCurrentMove && theme.palette.mode === "light"
? "1px solid #424242"
: undefined,
borderRadius: 1,
})}
id={`move-${moveIdx}`}
>
{color && (
<Image
src={`/icons/${moveClassification}.png`}
alt="move-icon"
width={14}
height={14}
style={{
maxWidth: "3.5vw",
maxHeight: "3.5vw",
}}
/>
)}
<Typography
color={getMoveColor(moveClassification)}
fontSize="0.9rem"
lineHeight="0.9rem"
>
{san}
</Typography>
</Grid>
);
}
const getMoveColor = (moveClassification?: MoveClassification) => {
if (
!moveClassification ||
moveClassificationsToIgnore.includes(moveClassification)
) {
return undefined;
}
return moveClassificationColors[moveClassification];
};
const moveClassificationsToIgnore: MoveClassification[] = [
MoveClassification.Good,
MoveClassification.Excellent,
];

View File

@@ -0,0 +1,29 @@
import { MoveClassification } from "@/types/enums";
import { Grid, Typography } from "@mui/material";
import MoveItem from "./moveItem";
interface Props {
moves: { san: string; moveClassification?: MoveClassification }[];
moveNb: number;
}
export default function MovesLine({ moves, moveNb }: Props) {
return (
<Grid
container
item
justifyContent="space-evenly"
alignItems="center"
xs={12}
wrap="nowrap"
>
<Typography width="2rem" fontSize="0.9rem">
{moveNb}.
</Typography>
<MoveItem {...moves[0]} moveIdx={(moveNb - 1) * 2 + 1} />
<MoveItem {...moves[1]} moveIdx={(moveNb - 1) * 2 + 2} />
</Grid>
);
}

View File

@@ -0,0 +1,39 @@
import { DotProps } from "recharts";
import { ChartItemData } from "./types";
import { useAtomValue } from "jotai";
import { boardAtom, gameAtom } from "../../states";
import { useChessActions } from "@/hooks/useChessActions";
import { moveClassificationColors } from "@/lib/chess";
export default function CustomDot({
cx,
cy,
r,
payload,
}: DotProps & { payload?: ChartItemData }) {
const { goToMove } = useChessActions(boardAtom);
const game = useAtomValue(gameAtom);
const handleDotClick = () => {
if (!payload) return;
goToMove(payload.moveNb, game);
};
const moveColor = payload?.moveClassification
? moveClassificationColors[payload.moveClassification]
: "grey";
return (
<circle
cx={cx}
cy={cy}
r={r}
stroke={moveColor}
strokeWidth={5}
fill={moveColor}
fillOpacity={1}
onClick={handleDotClick}
cursor="pointer"
/>
);
}

View File

@@ -0,0 +1,135 @@
import { Box, Grid, GridProps } from "@mui/material";
import { useAtomValue } from "jotai";
import {
Area,
AreaChart,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { currentPositionAtom, gameEvalAtom } from "../../states";
import { useMemo } from "react";
import CustomTooltip from "./tooltip";
import { ChartItemData } from "./types";
import { PositionEval } from "@/types/eval";
import { moveClassificationColors } from "@/lib/chess";
import CustomDot from "./dot";
export default function GraphTab(props: GridProps) {
const gameEval = useAtomValue(gameEvalAtom);
const currentPosition = useAtomValue(currentPositionAtom);
const chartData: ChartItemData[] = useMemo(
() => gameEval?.positions.map(formatEvalToChartData) ?? [],
[gameEval]
);
const boardMoveColor = currentPosition.eval?.moveClassification
? moveClassificationColors[currentPosition.eval.moveClassification]
: "grey";
if (!gameEval) return null;
return (
<Grid
container
item
justifyContent="center"
alignItems="start"
height="100%"
{...props}
sx={
props.hidden
? { display: "none" }
: { marginY: 1, overflow: "hidden", overflowY: "auto", ...props.sx }
}
>
<Box
width="max(35rem, 90%)"
maxWidth="100%"
height="max(8rem, 100%)"
maxHeight="15rem"
sx={{
backgroundColor: "#2e2e2e",
borderRadius: "15px",
overflow: "hidden",
}}
>
<ResponsiveContainer width="100%" height="100%">
<AreaChart
width={500}
height={400}
data={chartData}
margin={{ top: 0, left: 0, right: 0, bottom: 0 }}
>
<XAxis dataKey="moveNb" hide stroke="red" />
<YAxis domain={[0, 20]} hide />
<Tooltip
content={<CustomTooltip />}
isAnimationActive={false}
cursor={{
stroke: "grey",
strokeWidth: 2,
strokeOpacity: 0.3,
}}
/>
<Area
type="monotone"
dataKey="value"
stroke="none"
fill="#ffffff"
fillOpacity={1}
activeDot={<CustomDot />}
isAnimationActive={false}
/>
<ReferenceLine
y={10}
stroke="grey"
strokeWidth={2}
strokeOpacity={0.4}
/>
<ReferenceLine
x={currentPosition.currentMoveIdx}
stroke={boardMoveColor}
strokeWidth={4}
strokeOpacity={0.6}
/>
</AreaChart>
</ResponsiveContainer>
</Box>
</Grid>
);
}
const formatEvalToChartData = (
position: PositionEval,
index: number
): ChartItemData => {
const line = position.lines[0];
const chartItem: ChartItemData = {
moveNb: index,
value: 10,
cp: line.cp,
mate: line.mate,
moveClassification: position.moveClassification,
};
if (line.mate) {
return {
...chartItem,
value: line.mate > 0 ? 20 : 0,
};
}
if (line.cp) {
return {
...chartItem,
value: Math.max(Math.min(line.cp / 100, 10), -10) + 10,
};
}
return chartItem;
};

View File

@@ -0,0 +1,27 @@
import { TooltipProps } from "recharts";
import { ChartItemData } from "./types";
import { getLineEvalLabel } from "@/lib/chess";
export default function CustomTooltip({
active,
payload,
}: TooltipProps<number, number>) {
if (!active || !payload?.length) return null;
const data = payload[0].payload as ChartItemData;
return (
<div
style={{
backgroundColor: "#f0f0f0",
padding: 5,
color: "black",
opacity: 0.9,
border: "1px solid black",
borderRadius: 3,
}}
>
{getLineEvalLabel(data)}
</div>
);
}

View File

@@ -0,0 +1,9 @@
import { MoveClassification } from "@/types/enums";
export interface ChartItemData {
moveNb: number;
value: number;
cp?: number;
mate?: number;
moveClassification?: MoveClassification;
}