Upstream franciscoBSalgueiro/en-croissant state of the touched files before 512cbd9d53
This commit is contained in:
1 parent
01c5621cdb
commit
654f884a5c
3 files changed
+1728
No files matched your search
@@ -0,0 +1,815 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Group,
|
||||
NumberInput,
|
||||
Paper,
|
||||
Portal,
|
||||
ScrollArea,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { useToggle } from "@mantine/hooks";
|
||||
import {
|
||||
IconArrowsExchange,
|
||||
IconFileText,
|
||||
IconPlus,
|
||||
IconX,
|
||||
IconZoomCheck,
|
||||
} from "@tabler/icons-react";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import type { Piece } from "chessops";
|
||||
import { makeUci, parseUci } from "chessops";
|
||||
import { INITIAL_FEN } from "chessops/fen";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { match } from "ts-pattern";
|
||||
import { useStore } from "zustand";
|
||||
import type { Outcome } from "@/bindings";
|
||||
import {
|
||||
commands,
|
||||
type EngineLog,
|
||||
events,
|
||||
type GameConfig,
|
||||
type GameResult,
|
||||
type PlayerConfig,
|
||||
} from "@/bindings";
|
||||
import type { ChessgroundRef } from "@/chessground/Chessground";
|
||||
import {
|
||||
activeTabAtom,
|
||||
flipBoardAfterMoveAtom,
|
||||
currentGameIdAtom,
|
||||
currentGameStateAtom,
|
||||
currentPlayersAtom,
|
||||
gameInputColorAtom,
|
||||
gameOpeningBookEnabledAtom,
|
||||
gameOpeningBookMaxPlyAtom,
|
||||
gameOpeningBookPathAtom,
|
||||
gamePlayer1SettingsAtom,
|
||||
gamePlayer2SettingsAtom,
|
||||
gameSameTimeControlAtom,
|
||||
tabsAtom,
|
||||
} from "@/state/atoms";
|
||||
import { positionFromFen } from "@/utils/chessops";
|
||||
import type { GameHeaders } from "@/utils/treeReducer";
|
||||
import { unwrap } from "@/utils/unwrap";
|
||||
import EngineLogsView from "../common/EngineLogsView";
|
||||
import FileInput from "../common/FileInput";
|
||||
import GameInfo from "../common/GameInfo";
|
||||
import GameNotation from "../common/GameNotation";
|
||||
import MoveControls from "../common/MoveControls";
|
||||
import { TreeStateContext } from "../common/TreeStateContext";
|
||||
import Board from "./Board";
|
||||
import BoardControls from "./BoardControls";
|
||||
import EditingCard from "./EditingCard";
|
||||
import { OpponentForm, type OpponentSettings } from "./OpponentForm";
|
||||
|
||||
function gameResultToOutcome(result: GameResult): Outcome {
|
||||
if (result.type === "whiteWins") return "1-0";
|
||||
if (result.type === "blackWins") return "0-1";
|
||||
return "1/2-1/2";
|
||||
}
|
||||
|
||||
type BackendMove = { uci: string; clock: number | null };
|
||||
|
||||
function mapBackendMoves(moves: { uci: string; clock: bigint | null }[]): BackendMove[] {
|
||||
return moves.map((m) => ({
|
||||
uci: m.uci,
|
||||
clock: m.clock !== null ? Number(m.clock) : null,
|
||||
}));
|
||||
}
|
||||
|
||||
function BoardGame() {
|
||||
const { t } = useTranslation();
|
||||
const activeTab = useAtomValue(activeTabAtom);
|
||||
|
||||
const [editingMode, toggleEditingMode] = useToggle();
|
||||
const [selectedPiece, setSelectedPiece] = useState<Piece | null>(null);
|
||||
|
||||
const [inputColor, setInputColor] = useAtom(gameInputColorAtom);
|
||||
function cycleColor() {
|
||||
setInputColor((prev) =>
|
||||
match(prev)
|
||||
.with("white", () => "black" as const)
|
||||
.with("black", () => "random" as const)
|
||||
.with("random", () => "white" as const)
|
||||
.exhaustive(),
|
||||
);
|
||||
}
|
||||
|
||||
const [player1Settings, setPlayer1Settings] = useAtom(gamePlayer1SettingsAtom);
|
||||
const [player2Settings, setPlayer2Settings] = useAtom(gamePlayer2SettingsAtom);
|
||||
|
||||
function getPlayers() {
|
||||
let isPlayer1White = inputColor === "white";
|
||||
|
||||
if (inputColor === "random") {
|
||||
isPlayer1White = Math.random() > 0.5;
|
||||
}
|
||||
|
||||
return {
|
||||
white: isPlayer1White ? player1Settings : player2Settings,
|
||||
black: isPlayer1White ? player2Settings : player1Settings,
|
||||
};
|
||||
}
|
||||
|
||||
const store = useContext(TreeStateContext)!;
|
||||
const root = useStore(store, (s) => s.root);
|
||||
const headers = useStore(store, (s) => s.headers);
|
||||
const setFen = useStore(store, (s) => s.setFen);
|
||||
const setHeaders = useStore(store, (s) => s.setHeaders);
|
||||
const setResult = useStore(store, (s) => s.setResult);
|
||||
const appendMove = useStore(store, (s) => s.appendMove);
|
||||
const resetTree = useStore(store, (s) => s.reset);
|
||||
|
||||
const [, setTabs] = useAtom(tabsAtom);
|
||||
const autoFlipBoard = useAtomValue(flipBoardAfterMoveAtom);
|
||||
|
||||
const boardRef = useRef(null);
|
||||
const cgRef = useRef<ChessgroundRef>(null);
|
||||
const [gameState, setGameState] = useAtom(currentGameStateAtom);
|
||||
const [players, setPlayers] = useAtom(currentPlayersAtom);
|
||||
|
||||
const [whiteTime, setWhiteTime] = useState<number | null>(null);
|
||||
const [blackTime, setBlackTime] = useState<number | null>(null);
|
||||
const [gameId, setGameId] = useAtom(currentGameIdAtom);
|
||||
|
||||
const [logsOpened, toggleLogsOpened] = useToggle();
|
||||
const [logsColor, setLogsColor] = useState<"white" | "black">("white");
|
||||
const [engineLogs, setEngineLogs] = useState<EngineLog[]>([]);
|
||||
const [openingBookPath, setOpeningBookPath] = useAtom(gameOpeningBookPathAtom);
|
||||
const [openingBookEnabled, setOpeningBookEnabled] = useAtom(gameOpeningBookEnabledAtom);
|
||||
const [openingBookMaxPly, setOpeningBookMaxPly] = useAtom(gameOpeningBookMaxPlyAtom);
|
||||
|
||||
const hasEngine = players.white.type === "engine" || players.black.type === "engine";
|
||||
|
||||
const isPlayerVsEngine =
|
||||
(players.white.type === "human" && players.black.type === "engine") ||
|
||||
(players.black.type === "human" && players.white.type === "engine");
|
||||
|
||||
const orientation = headers.orientation || "white";
|
||||
const toggleOrientation = () => {
|
||||
setHeaders({
|
||||
...headers,
|
||||
fen: root.fen,
|
||||
orientation: orientation === "black" ? "white" : "black",
|
||||
});
|
||||
};
|
||||
|
||||
const fetchEngineLogs = useCallback(async () => {
|
||||
if (!gameId || !hasEngine) return;
|
||||
let color = logsColor;
|
||||
if (players.white.type === "human" && players.black.type === "engine") {
|
||||
color = "black";
|
||||
} else if (players.black.type === "human" && players.white.type === "engine") {
|
||||
color = "white";
|
||||
}
|
||||
const result = await commands.getGameEngineLogs(gameId, color);
|
||||
if (result.status === "ok") {
|
||||
setEngineLogs(result.data);
|
||||
}
|
||||
}, [gameId, logsColor, hasEngine, players.white.type, players.black.type]);
|
||||
|
||||
useEffect(() => {
|
||||
if (logsOpened) {
|
||||
fetchEngineLogs();
|
||||
}
|
||||
}, [logsOpened, fetchEngineLogs]);
|
||||
|
||||
const syncTreeWithMoves = useCallback(
|
||||
(backendMoves: BackendMove[]) => {
|
||||
const treeMoves: string[] = [];
|
||||
let node = root;
|
||||
while (node.children.length > 0) {
|
||||
node = node.children[0];
|
||||
if (node.move) {
|
||||
treeMoves.push(makeUci(node.move));
|
||||
}
|
||||
}
|
||||
|
||||
let needsReset = false;
|
||||
for (let i = 0; i < treeMoves.length; i++) {
|
||||
if (i >= backendMoves.length || treeMoves[i] !== backendMoves[i].uci) {
|
||||
needsReset = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (needsReset) {
|
||||
setFen(root.fen);
|
||||
for (const move of backendMoves) {
|
||||
const parsed = parseUci(move.uci);
|
||||
if (parsed) {
|
||||
appendMove({
|
||||
payload: parsed,
|
||||
clock: move.clock !== null ? Number(move.clock) : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (backendMoves.length > treeMoves.length) {
|
||||
for (let i = treeMoves.length; i < backendMoves.length; i++) {
|
||||
const move = backendMoves[i];
|
||||
const parsed = parseUci(move.uci);
|
||||
if (parsed) {
|
||||
appendMove({
|
||||
payload: parsed,
|
||||
clock: move.clock !== null ? Number(move.clock) : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
[root, setFen, appendMove],
|
||||
);
|
||||
|
||||
function changeToAnalysisMode() {
|
||||
setTabs((prev) =>
|
||||
prev.map((tab) => (tab.value === activeTab ? { ...tab, type: "analysis" } : tab)),
|
||||
);
|
||||
}
|
||||
|
||||
const [pos, error] = useMemo(() => {
|
||||
let node = root;
|
||||
while (node.children.length > 0) {
|
||||
node = node.children[0];
|
||||
}
|
||||
return positionFromFen(node.fen);
|
||||
}, [root]);
|
||||
|
||||
function toPlayerConfig(settings: OpponentSettings): PlayerConfig {
|
||||
if (settings.type === "human") {
|
||||
return {
|
||||
type: "human",
|
||||
name: settings.name ?? "Player",
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "engine",
|
||||
name: settings.engine?.name ?? "Engine",
|
||||
path: settings.engine?.path ?? "",
|
||||
options: (settings.engineSettings ?? settings.engine?.settings ?? [])
|
||||
.filter((s) => s.name !== "MultiPV")
|
||||
.map((s) => ({
|
||||
name: s.name,
|
||||
value: s.value?.toString() ?? "",
|
||||
})),
|
||||
go: settings.timeControl ? null : settings.go,
|
||||
};
|
||||
}
|
||||
|
||||
function getTreeMoves(): string[] {
|
||||
const moves: string[] = [];
|
||||
let node = root;
|
||||
while (node.children.length > 0) {
|
||||
node = node.children[0];
|
||||
if (node.move) {
|
||||
moves.push(makeUci(node.move));
|
||||
}
|
||||
}
|
||||
return moves;
|
||||
}
|
||||
|
||||
async function startGame() {
|
||||
const playerSettings = getPlayers();
|
||||
setPlayers(playerSettings);
|
||||
|
||||
const boardOrientation =
|
||||
playerSettings.black.type === "human" && playerSettings.white.type === "engine"
|
||||
? "black"
|
||||
: "white";
|
||||
|
||||
const newGameId = `${activeTab}-game`;
|
||||
setGameId(newGameId);
|
||||
|
||||
const initialMoves = getTreeMoves();
|
||||
|
||||
const config: GameConfig = {
|
||||
white: toPlayerConfig(playerSettings.white),
|
||||
black: toPlayerConfig(playerSettings.black),
|
||||
whiteTimeControl: playerSettings.white.timeControl
|
||||
? {
|
||||
initialTime: playerSettings.white.timeControl.seconds,
|
||||
increment: playerSettings.white.timeControl.increment ?? 0,
|
||||
}
|
||||
: null,
|
||||
blackTimeControl: playerSettings.black.timeControl
|
||||
? {
|
||||
initialTime: playerSettings.black.timeControl.seconds,
|
||||
increment: playerSettings.black.timeControl.increment ?? 0,
|
||||
}
|
||||
: null,
|
||||
initialFen: root.fen === INITIAL_FEN ? null : root.fen,
|
||||
initialMoves,
|
||||
openingBook:
|
||||
openingBookEnabled && openingBookPath
|
||||
? { path: openingBookPath, maxPly: Math.max(1, openingBookMaxPly) }
|
||||
: null,
|
||||
} as GameConfig;
|
||||
|
||||
try {
|
||||
const result = await commands.startGame(newGameId, config);
|
||||
const state = unwrap(result);
|
||||
|
||||
setWhiteTime(state.whiteTime !== null ? Number(state.whiteTime) : null);
|
||||
setBlackTime(state.blackTime !== null ? Number(state.blackTime) : null);
|
||||
|
||||
setGameState("playing");
|
||||
|
||||
setFen(state.initialFen);
|
||||
for (const move of mapBackendMoves(state.moves)) {
|
||||
const parsed = parseUci(move.uci);
|
||||
if (parsed) {
|
||||
appendMove({
|
||||
payload: parsed,
|
||||
clock: move.clock ?? undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const dateStr = now.toISOString().slice(0, 10).replace(/-/g, ".");
|
||||
const timeStr = now.toISOString().slice(11, 19);
|
||||
|
||||
const whiteIsEngine = playerSettings.white.type === "engine";
|
||||
const blackIsEngine = playerSettings.black.type === "engine";
|
||||
let eventStr = "Casual Game";
|
||||
if (whiteIsEngine && blackIsEngine) {
|
||||
eventStr = "Engine Match";
|
||||
} else if (whiteIsEngine || blackIsEngine) {
|
||||
eventStr = "Player vs Engine";
|
||||
} else {
|
||||
eventStr = "Player Match";
|
||||
}
|
||||
|
||||
const formatTimeControl = (settings: OpponentSettings): string => {
|
||||
if (!settings.timeControl) return "-";
|
||||
const seconds = settings.timeControl.seconds / 1000;
|
||||
const increment = (settings.timeControl.increment ?? 0) / 1000;
|
||||
return increment ? `${seconds}+${increment}` : `${seconds}`;
|
||||
};
|
||||
|
||||
const whiteTimeControl = formatTimeControl(playerSettings.white);
|
||||
const blackTimeControl = formatTimeControl(playerSettings.black);
|
||||
const sameTimeControl = whiteTimeControl === blackTimeControl;
|
||||
|
||||
const newHeaders: Partial<GameHeaders> = {
|
||||
white: state.whitePlayer,
|
||||
black: state.blackPlayer,
|
||||
event: eventStr,
|
||||
site: "En Croissant",
|
||||
date: dateStr,
|
||||
time: timeStr,
|
||||
time_control: undefined,
|
||||
orientation: boardOrientation,
|
||||
};
|
||||
|
||||
if (sameTimeControl) {
|
||||
if (whiteTimeControl !== "-") {
|
||||
newHeaders.time_control = whiteTimeControl;
|
||||
}
|
||||
} else {
|
||||
newHeaders.white_time_control = whiteTimeControl;
|
||||
newHeaders.black_time_control = blackTimeControl;
|
||||
}
|
||||
|
||||
setHeaders({
|
||||
...headers,
|
||||
...newHeaders,
|
||||
fen: state.initialFen,
|
||||
});
|
||||
|
||||
setTabs((prev) =>
|
||||
prev.map((tab) =>
|
||||
tab.value === activeTab
|
||||
? { ...tab, name: `${state.whitePlayer} vs. ${state.blackPlayer}` }
|
||||
: tab,
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Failed to start game:", err);
|
||||
}
|
||||
}
|
||||
|
||||
const handleHumanMove = useCallback(
|
||||
async (uci: string) => {
|
||||
if (!gameId || gameState !== "playing") return;
|
||||
try {
|
||||
const result = await commands.makeGameMove(gameId, uci);
|
||||
if (!isPlayerVsEngine && autoFlipBoard) {
|
||||
toggleOrientation();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to make move:", err);
|
||||
}
|
||||
},
|
||||
[gameId, gameState, toggleOrientation],
|
||||
);
|
||||
|
||||
const pendingMovesRef = useRef<{ uci: string; clock: number | null }[] | null>(null);
|
||||
const pendingTimesRef = useRef<{
|
||||
white: number | null;
|
||||
black: number | null;
|
||||
} | null>(null);
|
||||
const throttleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const THROTTLE_MS = 150;
|
||||
|
||||
const syncTreeWithMovesRef = useRef(syncTreeWithMoves);
|
||||
syncTreeWithMovesRef.current = syncTreeWithMoves;
|
||||
|
||||
const applyPendingUpdates = useCallback(() => {
|
||||
if (pendingMovesRef.current) {
|
||||
syncTreeWithMovesRef.current(pendingMovesRef.current);
|
||||
pendingMovesRef.current = null;
|
||||
}
|
||||
if (pendingTimesRef.current) {
|
||||
setWhiteTime(pendingTimesRef.current.white);
|
||||
setBlackTime(pendingTimesRef.current.black);
|
||||
pendingTimesRef.current = null;
|
||||
}
|
||||
throttleTimerRef.current = null;
|
||||
|
||||
setTimeout(() => {
|
||||
cgRef.current?.playPremove();
|
||||
}, 0);
|
||||
}, []);
|
||||
|
||||
const scheduleUpdate = useCallback(() => {
|
||||
if (!throttleTimerRef.current) {
|
||||
throttleTimerRef.current = setTimeout(applyPendingUpdates, THROTTLE_MS);
|
||||
}
|
||||
}, [applyPendingUpdates]);
|
||||
|
||||
const onTakeBack = useCallback(async () => {
|
||||
if (!gameId || gameState !== "playing") return;
|
||||
const result = await commands.takeBackGameMove(gameId);
|
||||
}, [gameId, gameState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (gameState !== "playing" || !gameId) return;
|
||||
|
||||
const currentGameId = gameId;
|
||||
|
||||
const unlistenMove = events.gameMoveEvent.listen(({ payload }) => {
|
||||
if (payload.gameId !== currentGameId) return;
|
||||
|
||||
pendingMovesRef.current = mapBackendMoves(payload.moves);
|
||||
pendingTimesRef.current = {
|
||||
white: payload.whiteTime !== null ? Number(payload.whiteTime) : null,
|
||||
black: payload.blackTime !== null ? Number(payload.blackTime) : null,
|
||||
};
|
||||
scheduleUpdate();
|
||||
});
|
||||
|
||||
const unlistenClock = events.clockUpdateEvent.listen(({ payload }) => {
|
||||
if (payload.gameId !== currentGameId) return;
|
||||
setWhiteTime(payload.whiteTime !== null ? Number(payload.whiteTime) : null);
|
||||
setBlackTime(payload.blackTime !== null ? Number(payload.blackTime) : null);
|
||||
});
|
||||
|
||||
const unlistenGameOver = events.gameOverEvent.listen(({ payload }) => {
|
||||
if (payload.gameId !== currentGameId) return;
|
||||
|
||||
if (throttleTimerRef.current) {
|
||||
clearTimeout(throttleTimerRef.current);
|
||||
throttleTimerRef.current = null;
|
||||
}
|
||||
pendingMovesRef.current = null;
|
||||
pendingTimesRef.current = null;
|
||||
|
||||
syncTreeWithMovesRef.current(mapBackendMoves(payload.moves));
|
||||
|
||||
setGameState("gameOver");
|
||||
setResult(gameResultToOutcome(payload.result));
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (throttleTimerRef.current) {
|
||||
clearTimeout(throttleTimerRef.current);
|
||||
throttleTimerRef.current = null;
|
||||
}
|
||||
unlistenMove.then((f) => f());
|
||||
unlistenClock.then((f) => f());
|
||||
unlistenGameOver.then((f) => f());
|
||||
};
|
||||
}, [gameId, gameState, scheduleUpdate, setGameState, setResult]);
|
||||
|
||||
useEffect(() => {
|
||||
if (gameState === "playing" && gameId) {
|
||||
commands.getGameState(gameId).then((result) => {
|
||||
if (result.status === "ok") {
|
||||
const state = result.data;
|
||||
|
||||
syncTreeWithMovesRef.current(mapBackendMoves(state.moves));
|
||||
|
||||
setWhiteTime(state.whiteTime !== null ? Number(state.whiteTime) : null);
|
||||
setBlackTime(state.blackTime !== null ? Number(state.blackTime) : null);
|
||||
|
||||
if (state.status !== "playing") {
|
||||
setGameState("gameOver");
|
||||
if (typeof state.status === "object" && "finished" in state.status) {
|
||||
setResult(gameResultToOutcome(state.status.finished.result));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [gameId, gameState, setGameState, setResult]);
|
||||
|
||||
const movable = useMemo(() => {
|
||||
if (players.white.type === "human" && players.black.type === "human") {
|
||||
return "turn";
|
||||
}
|
||||
if (players.white.type === "human") {
|
||||
return "white";
|
||||
}
|
||||
if (players.black.type === "human") {
|
||||
return "black";
|
||||
}
|
||||
return "none";
|
||||
}, [players]);
|
||||
|
||||
const [sameTimeControl, setSameTimeControl] = useAtom(gameSameTimeControlAtom);
|
||||
|
||||
const onePlayerIsEngine = players.white.type !== players.black.type;
|
||||
const isEngineVsEngine = players.white.type === "engine" && players.black.type === "engine";
|
||||
|
||||
function getResignationLosingColor(): "white" | "black" {
|
||||
if (isPlayerVsEngine) {
|
||||
return players.white.type === "human" ? "white" : "black";
|
||||
}
|
||||
return pos?.turn === "white" ? "white" : "black";
|
||||
}
|
||||
|
||||
async function handleAbort() {
|
||||
if (!gameId) return;
|
||||
await commands.abortGame(gameId);
|
||||
setGameState("gameOver");
|
||||
setResult("*");
|
||||
}
|
||||
|
||||
async function handleResign() {
|
||||
if (!gameId) return;
|
||||
const losingColor = getResignationLosingColor();
|
||||
await commands.resignGame(gameId, losingColor);
|
||||
}
|
||||
|
||||
async function handleNewGame() {
|
||||
setGameId(null);
|
||||
setGameState("settingUp");
|
||||
setWhiteTime(null);
|
||||
setBlackTime(null);
|
||||
resetTree();
|
||||
}
|
||||
|
||||
async function handleSelectOpeningBook() {
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
filters: [
|
||||
{
|
||||
name: "Opening Book",
|
||||
extensions: ["pgn", "epd", "bin", "zip"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (typeof selected === "string") {
|
||||
setOpeningBookPath(selected);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Portal target="#left" style={{ height: "100%" }}>
|
||||
<Board
|
||||
editingMode={gameState === "settingUp" && editingMode}
|
||||
viewOnly={gameState !== "playing" && !editingMode}
|
||||
disableVariations
|
||||
boardRef={boardRef}
|
||||
movable={gameState === "settingUp" && editingMode ? "none" : movable}
|
||||
whiteTime={gameState === "playing" ? (whiteTime ?? undefined) : undefined}
|
||||
blackTime={gameState === "playing" ? (blackTime ?? undefined) : undefined}
|
||||
onMove={handleHumanMove}
|
||||
selectedPiece={selectedPiece}
|
||||
cgRef={cgRef}
|
||||
enablePremoves={isPlayerVsEngine && gameState === "playing"}
|
||||
/>
|
||||
</Portal>
|
||||
<Portal target="#topRight" style={{ height: "100%", overflow: "hidden" }}>
|
||||
<Paper withBorder shadow="sm" p="md" h="100%">
|
||||
{logsOpened ? (
|
||||
<EngineLogsView
|
||||
logs={engineLogs}
|
||||
onRefresh={fetchEngineLogs}
|
||||
additionalControls={
|
||||
<>
|
||||
{players.white.type === "engine" && players.black.type === "engine" ? (
|
||||
<SegmentedControl
|
||||
value={logsColor}
|
||||
onChange={(value) => setLogsColor(value as "white" | "black")}
|
||||
data={[
|
||||
{ value: "white", label: "White" },
|
||||
{ value: "black", label: "Black" },
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<ActionIcon flex={0} onClick={() => toggleLogsOpened()}>
|
||||
<IconX size="1.2rem" />
|
||||
</ActionIcon>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{gameState === "settingUp" && (
|
||||
<Stack h="100%" gap={0}>
|
||||
<ScrollArea style={{ flex: 1 }} offsetScrollbars>
|
||||
<Stack>
|
||||
<Group>
|
||||
<Text flex={1} ta="center" fz="lg" fw="bold">
|
||||
{match(inputColor)
|
||||
.with("white", () => "White")
|
||||
.with("random", () => "Random")
|
||||
.with("black", () => "Black")
|
||||
.exhaustive()}
|
||||
</Text>
|
||||
<ActionIcon onClick={cycleColor}>
|
||||
<IconArrowsExchange />
|
||||
</ActionIcon>
|
||||
<Text flex={1} ta="center" fz="lg" fw="bold">
|
||||
{match(inputColor)
|
||||
.with("white", () => "Black")
|
||||
.with("random", () => "Random")
|
||||
.with("black", () => "White")
|
||||
.exhaustive()}
|
||||
</Text>
|
||||
</Group>
|
||||
<Box flex={1}>
|
||||
<Group style={{ alignItems: "start" }}>
|
||||
<OpponentForm
|
||||
sameTimeControl={sameTimeControl}
|
||||
opponent={player1Settings}
|
||||
setOpponent={setPlayer1Settings}
|
||||
setOtherOpponent={setPlayer2Settings}
|
||||
/>
|
||||
<Divider orientation="vertical" />
|
||||
<OpponentForm
|
||||
sameTimeControl={sameTimeControl}
|
||||
opponent={player2Settings}
|
||||
setOpponent={setPlayer2Settings}
|
||||
setOtherOpponent={setPlayer1Settings}
|
||||
/>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
<Paper withBorder p="sm">
|
||||
<Stack>
|
||||
<Checkbox
|
||||
label={t("Board.Opponent.SameTimeControl")}
|
||||
checked={sameTimeControl}
|
||||
onChange={(e) => {
|
||||
const checked = e.target.checked;
|
||||
setSameTimeControl(checked);
|
||||
if (checked) {
|
||||
setPlayer2Settings((prev) => ({
|
||||
...prev,
|
||||
timeControl: player1Settings.timeControl,
|
||||
timeUnit: player1Settings.timeUnit,
|
||||
incrementUnit: player1Settings.incrementUnit,
|
||||
}));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Divider variant="dashed" />
|
||||
|
||||
<Checkbox
|
||||
label={t("Board.Opponent.EnableOpeningBook")}
|
||||
checked={openingBookEnabled}
|
||||
onChange={(e) => setOpeningBookEnabled(e.currentTarget.checked)}
|
||||
/>
|
||||
|
||||
{openingBookEnabled && (
|
||||
<>
|
||||
<FileInput
|
||||
label="Opening book (.pgn/.epd/.bin/.zip)"
|
||||
description={t("Import.PGN.ClickToSelect")}
|
||||
filename={openingBookPath}
|
||||
onClick={handleSelectOpeningBook}
|
||||
/>
|
||||
{openingBookPath?.includes(".bin") && (
|
||||
<NumberInput
|
||||
label="Polyglot max plies"
|
||||
description="Maximum number of plies from the starting position that the opening book will be used for."
|
||||
min={1}
|
||||
value={openingBookMaxPly}
|
||||
onChange={(value) => {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
setOpeningBookMaxPly(Math.max(1, Math.trunc(value)));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
|
||||
<Divider pb="sm" />
|
||||
<Button onClick={startGame} fullWidth variant="light" disabled={error !== null}>
|
||||
{t("Board.Opponent.StartGame")}
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
{(gameState === "playing" || gameState === "gameOver") && (
|
||||
<Stack h="100%">
|
||||
<Box flex={1}>
|
||||
<GameInfo headers={headers} />
|
||||
</Box>
|
||||
<Group grow>
|
||||
{gameState === "playing" && (
|
||||
<Button
|
||||
variant="default"
|
||||
color="red"
|
||||
onClick={isEngineVsEngine ? handleAbort : handleResign}
|
||||
leftSection={<IconX />}
|
||||
>
|
||||
{isEngineVsEngine ? "Abort" : "Resign"}
|
||||
</Button>
|
||||
)}
|
||||
{gameState === "gameOver" && (
|
||||
<Button variant="default" onClick={handleNewGame} leftSection={<IconPlus />}>
|
||||
New Game
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => changeToAnalysisMode()}
|
||||
leftSection={<IconZoomCheck />}
|
||||
>
|
||||
Analyze
|
||||
</Button>
|
||||
|
||||
{hasEngine && (
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => toggleLogsOpened()}
|
||||
leftSection={<IconFileText size="1rem" />}
|
||||
>
|
||||
Engine Logs
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
</Portal>
|
||||
<Portal target="#bottomRight" style={{ height: "100%" }}>
|
||||
{gameState === "settingUp" && editingMode ? (
|
||||
<EditingCard
|
||||
boardRef={boardRef}
|
||||
setEditingMode={toggleEditingMode}
|
||||
selectedPiece={selectedPiece}
|
||||
setSelectedPiece={setSelectedPiece}
|
||||
/>
|
||||
) : (
|
||||
<Stack h="100%" gap="xs">
|
||||
<GameNotation
|
||||
topBar
|
||||
controls={
|
||||
<BoardControls
|
||||
editingMode={gameState === "settingUp" && editingMode}
|
||||
toggleEditingMode={toggleEditingMode}
|
||||
dirty={false}
|
||||
canTakeBack={onePlayerIsEngine}
|
||||
onTakeBack={onTakeBack}
|
||||
disableVariations
|
||||
allowEditing={gameState === "settingUp"}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<MoveControls />
|
||||
</Stack>
|
||||
)}
|
||||
</Portal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default BoardGame;
|
||||
@@ -0,0 +1,218 @@
|
||||
import { ActionIcon, Box, Menu, Portal, Text, Tooltip } from "@mantine/core";
|
||||
import { useClickOutside } from "@mantine/hooks";
|
||||
import {
|
||||
IconArrowsJoin,
|
||||
IconChevronsUp,
|
||||
IconChevronUp,
|
||||
IconCopy,
|
||||
IconFlag,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import equal from "fast-deep-equal";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { memo, useContext, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useStore } from "zustand";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import Comment from "@/components/common/Comment";
|
||||
import { currentTabAtom } from "@/state/atoms";
|
||||
import type { Annotation } from "@/utils/annotation";
|
||||
import { hasMorePriority, stripClock } from "@/utils/chess";
|
||||
import { getTabFile } from "@/utils/tabs";
|
||||
import { type TreeNode, treeIterator } from "@/utils/treeReducer";
|
||||
import MoveCell from "./MoveCell";
|
||||
import { TreeStateContext } from "./TreeStateContext";
|
||||
|
||||
const transpositionCache = new WeakMap<TreeNode, Map<string, number[][]>>();
|
||||
|
||||
function getTranspositionMap(root: TreeNode) {
|
||||
if (transpositionCache.has(root)) {
|
||||
return transpositionCache.get(root)!;
|
||||
}
|
||||
|
||||
const map = new Map<string, number[][]>();
|
||||
const iterator = treeIterator(root);
|
||||
|
||||
for (const item of iterator) {
|
||||
const strippedFen = stripClock(item.node.fen);
|
||||
if (!map.has(strippedFen)) {
|
||||
map.set(strippedFen, []);
|
||||
}
|
||||
map.get(strippedFen)!.push(item.position);
|
||||
}
|
||||
|
||||
transpositionCache.set(root, map);
|
||||
return map;
|
||||
}
|
||||
|
||||
function getTranspositions(fen: string, position: number[], root: TreeNode) {
|
||||
if (position.length === 0 || position.every((v) => v === 0)) return [];
|
||||
|
||||
const map = getTranspositionMap(root);
|
||||
const strippedFen = stripClock(fen);
|
||||
|
||||
const matchingPositions = map.get(strippedFen) || [];
|
||||
|
||||
return matchingPositions.filter((targetPosition) => !hasMorePriority(position, targetPosition));
|
||||
}
|
||||
|
||||
function CompleteMoveCell({
|
||||
movePath,
|
||||
halfMoves,
|
||||
move,
|
||||
fen,
|
||||
comment,
|
||||
annotations,
|
||||
showComments,
|
||||
first,
|
||||
targetRef,
|
||||
tableLayout,
|
||||
scoreText,
|
||||
}: {
|
||||
halfMoves: number;
|
||||
comment: string;
|
||||
annotations: Annotation[];
|
||||
showComments: boolean;
|
||||
move?: string | null;
|
||||
fen?: string;
|
||||
first?: boolean;
|
||||
movePath: number[];
|
||||
targetRef: React.RefObject<HTMLSpanElement | null>;
|
||||
tableLayout?: boolean;
|
||||
scoreText?: string;
|
||||
}) {
|
||||
const store = useContext(TreeStateContext)!;
|
||||
const isStart = useStore(store, (s) => equal(movePath, s.headers.start));
|
||||
|
||||
const isCurrentVariation = useStore(store, (s) => equal(s.position, movePath));
|
||||
const transpositions = useStoreWithEqualityFn(
|
||||
store,
|
||||
(s) => (fen ? getTranspositions(fen, movePath, s.root) : []),
|
||||
(a, b) => equal(a, b),
|
||||
);
|
||||
const goToMove = useStore(store, (s) => s.goToMove);
|
||||
const deleteMove = useStore(store, (s) => s.deleteMove);
|
||||
const promoteVariation = useStore(store, (s) => s.promoteVariation);
|
||||
const promoteToMainline = useStore(store, (s) => s.promoteToMainline);
|
||||
const copyVariationPgn = useStore(store, (s) => s.copyVariationPgn);
|
||||
const setStart = useStore(store, (s) => s.setStart);
|
||||
|
||||
const moveNumber = Math.ceil(halfMoves / 2);
|
||||
const isWhite = halfMoves % 2 === 1;
|
||||
const hasNumber = !tableLayout && halfMoves > 0 && (first || isWhite);
|
||||
const ref = useClickOutside(() => {
|
||||
setOpen(false);
|
||||
});
|
||||
const [open, setOpen] = useState(false);
|
||||
const currentTab = useAtomValue(currentTabAtom);
|
||||
const tabFile = getTabFile(currentTab);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
ref={isCurrentVariation ? targetRef : undefined}
|
||||
component="span"
|
||||
style={{
|
||||
display: tableLayout ? "block" : "inline-block",
|
||||
marginLeft: hasNumber ? 6 : 0,
|
||||
fontSize: "80%",
|
||||
width: tableLayout ? "100%" : undefined,
|
||||
}}
|
||||
>
|
||||
{hasNumber && `${moveNumber.toString()}${isWhite ? "." : "..."}`}
|
||||
{move && (
|
||||
<Menu opened={open} width={200}>
|
||||
<Menu.Target>
|
||||
<MoveCell
|
||||
ref={ref}
|
||||
move={move}
|
||||
annotations={annotations}
|
||||
isStart={isStart}
|
||||
isCurrentVariation={isCurrentVariation}
|
||||
onClick={() => goToMove(movePath)}
|
||||
onContextMenu={(e: React.MouseEvent) => {
|
||||
setOpen((v) => !v);
|
||||
e.preventDefault();
|
||||
}}
|
||||
fullWidth={tableLayout}
|
||||
rightAccessory={
|
||||
tableLayout && scoreText ? (
|
||||
<Text component="span" size="xs" c="dimmed">
|
||||
{scoreText}
|
||||
</Text>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</Menu.Target>
|
||||
|
||||
<Portal>
|
||||
<Menu.Dropdown>
|
||||
{tabFile?.metadata.type === "repertoire" && (
|
||||
<Menu.Item
|
||||
leftSection={<IconFlag size="0.875rem" />}
|
||||
onClick={() => setStart(movePath)}
|
||||
>
|
||||
{t("Menu.MarkAsStart")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item
|
||||
leftSection={<IconChevronsUp size="0.875rem" />}
|
||||
onClick={() => promoteToMainline(movePath)}
|
||||
>
|
||||
{t("Menu.PromoteToMainLine")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<IconChevronUp size="0.875rem" />}
|
||||
onClick={() => promoteVariation(movePath)}
|
||||
>
|
||||
{t("Menu.PromoteVariation")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<IconCopy size="0.875rem" />}
|
||||
onClick={() => copyVariationPgn(movePath)}
|
||||
>
|
||||
{t("Menu.CopyVariationPGN")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<IconX size="0.875rem" />}
|
||||
onClick={() => deleteMove(movePath)}
|
||||
>
|
||||
{t("Menu.DeleteMove")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Portal>
|
||||
</Menu>
|
||||
)}
|
||||
{transpositions.length > 0 && (
|
||||
<Tooltip label="Transposition">
|
||||
<ActionIcon size="xs" onClick={() => goToMove(transpositions[0])}>
|
||||
<IconArrowsJoin size="0.875rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
{showComments && !tableLayout && comment && <Comment comment={comment} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(CompleteMoveCell, (prev, next) => {
|
||||
return (
|
||||
prev.move === next.move &&
|
||||
prev.fen === next.fen &&
|
||||
prev.comment === next.comment &&
|
||||
equal(prev.annotations, next.annotations) &&
|
||||
prev.showComments === next.showComments &&
|
||||
prev.first === next.first &&
|
||||
equal(prev.movePath, next.movePath) &&
|
||||
prev.halfMoves === next.halfMoves &&
|
||||
prev.tableLayout === next.tableLayout &&
|
||||
prev.scoreText === next.scoreText
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user