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
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,695 @@
|
||||
import type { MantineColor } from "@mantine/core";
|
||||
import { resolve } from "@tauri-apps/api/path";
|
||||
import { exists, mkdir, readTextFile, writeTextFile } from "@tauri-apps/plugin-fs";
|
||||
import { parseUci } from "chessops";
|
||||
import { INITIAL_FEN, makeFen } from "chessops/fen";
|
||||
import equal from "fast-deep-equal";
|
||||
import { atom, type PrimitiveAtom } from "jotai";
|
||||
import { atomFamily, atomWithStorage, createJSONStorage, unwrap } from "jotai/utils";
|
||||
import type { AtomFamily } from "jotai/vanilla/utils/atomFamily";
|
||||
import type {
|
||||
AsyncStorage,
|
||||
AsyncStringStorage,
|
||||
SyncStorage,
|
||||
} from "jotai/vanilla/utils/atomWithStorage";
|
||||
import type { ReviewLog } from "ts-fsrs";
|
||||
import { z } from "zod";
|
||||
import type { BestMoves, GoMode } from "@/bindings";
|
||||
import { DEFAULT_TIME_CONTROL, type OpponentSettings } from "@/components/boards/OpponentForm";
|
||||
import { type Position, positionSchema } from "@/components/files/opening";
|
||||
import type { LocalOptions } from "@/components/panels/database/DatabasePanel";
|
||||
import { positionFromFen, swapMove } from "@/utils/chessops";
|
||||
import type { SuccessDatabaseInfo } from "@/utils/db";
|
||||
import { type Engine, type EngineSettings, engineSchema } from "@/utils/engines";
|
||||
import {
|
||||
type LichessGamesOptions,
|
||||
lichessGamesOptionsSchema,
|
||||
type MasterGamesOptions,
|
||||
masterOptionsSchema,
|
||||
} from "@/utils/lichess/explorer";
|
||||
import { getWinChance, normalizeScore } from "@/utils/score";
|
||||
import { genID, type Tab, tabSchema } from "@/utils/tabs";
|
||||
import { getEnginesDir } from "../utils/directories";
|
||||
import type { Session } from "../utils/session";
|
||||
import { createAsyncZodStorage, createZodStorage } from "./utils";
|
||||
|
||||
const zodArray = <Input, Output>(itemSchema: z.ZodType<Output, z.ZodTypeDef, Input>) => {
|
||||
const catchValue = {} as never;
|
||||
|
||||
const res = z
|
||||
.array(itemSchema.catch(catchValue))
|
||||
.transform((a) => a.filter((o): o is Output => o !== catchValue))
|
||||
.catch([]);
|
||||
|
||||
return res as z.ZodType<Output[], z.ZodTypeDef, Input[]>;
|
||||
};
|
||||
|
||||
// Tabs
|
||||
|
||||
const firstTab: Tab = {
|
||||
name: "Tab.NewTab",
|
||||
value: genID(),
|
||||
type: "new",
|
||||
gameOrigin: {
|
||||
kind: "none",
|
||||
},
|
||||
};
|
||||
|
||||
export const tabsAtom = atomWithStorage<Tab[]>(
|
||||
"tabs",
|
||||
[firstTab],
|
||||
createZodStorage(z.array(tabSchema), sessionStorage),
|
||||
);
|
||||
|
||||
export const activeTabAtom = atomWithStorage<string | null>(
|
||||
"activeTab",
|
||||
firstTab.value,
|
||||
createJSONStorage(() => sessionStorage),
|
||||
);
|
||||
|
||||
export const expandedDirectoriesAtom = atomWithStorage<string[]>(
|
||||
"expanded-directories",
|
||||
[],
|
||||
createZodStorage(z.array(z.string()), sessionStorage),
|
||||
);
|
||||
|
||||
export const currentTabAtom = atom(
|
||||
(get) => {
|
||||
const tabs = get(tabsAtom);
|
||||
const activeTab = get(activeTabAtom);
|
||||
return tabs.find((tab) => tab.value === activeTab);
|
||||
},
|
||||
(get, set, newValue: Tab | ((currentTab: Tab) => Tab)) => {
|
||||
const tabs = get(tabsAtom);
|
||||
const activeTab = get(activeTabAtom);
|
||||
const nextValue =
|
||||
typeof newValue === "function" ? newValue(get(currentTabAtom)!) : newValue;
|
||||
const newTabs = tabs.map((tab) => {
|
||||
if (tab.value === activeTab) {
|
||||
return nextValue;
|
||||
}
|
||||
return tab;
|
||||
});
|
||||
set(tabsAtom, newTabs);
|
||||
},
|
||||
);
|
||||
|
||||
// Directories
|
||||
export const storedDocumentDirAtom = atomWithStorage<string>("document-dir", "", undefined, {
|
||||
getOnInit: true,
|
||||
});
|
||||
export const storedDatabasesDirAtom = atomWithStorage<string>("databases-dir", "", undefined, {
|
||||
getOnInit: true,
|
||||
});
|
||||
export const storedEnginesDirAtom = atomWithStorage<string>("engines-dir", "", undefined, {
|
||||
getOnInit: true,
|
||||
});
|
||||
export const storedPuzzlesDirAtom = atomWithStorage<string>("puzzles-dir", "", undefined, {
|
||||
getOnInit: true,
|
||||
});
|
||||
|
||||
async function ensureParentDir(path: string): Promise<void> {
|
||||
const separator = path.includes("\\") ? "\\" : "/";
|
||||
const lastSeparator = path.lastIndexOf(separator);
|
||||
if (lastSeparator === -1) return;
|
||||
|
||||
const parentDir = path.slice(0, lastSeparator);
|
||||
if (!parentDir) return;
|
||||
|
||||
if (!(await exists(parentDir))) {
|
||||
await mkdir(parentDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function getEnginesStoragePath(key: string): Promise<string> {
|
||||
const enginesDir = await getEnginesDir();
|
||||
const filename = key.replace(/^engines[\\/]/, "");
|
||||
return resolve(enginesDir, filename);
|
||||
}
|
||||
|
||||
const enginesFileStorage: AsyncStringStorage = {
|
||||
async getItem(key) {
|
||||
try {
|
||||
return await readTextFile(await getEnginesStoragePath(key));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
async setItem(key, newValue) {
|
||||
const path = await getEnginesStoragePath(key);
|
||||
await ensureParentDir(path);
|
||||
await writeTextFile(path, newValue);
|
||||
},
|
||||
async removeItem(key) {
|
||||
const path = await getEnginesStoragePath(key);
|
||||
try {
|
||||
await writeTextFile(path, "[]");
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const enginesAtom = unwrap(
|
||||
atomWithStorage<Engine[]>(
|
||||
"engines/engines.json",
|
||||
[],
|
||||
createAsyncZodStorage(zodArray(engineSchema), enginesFileStorage) as AsyncStorage<Engine[]>,
|
||||
),
|
||||
);
|
||||
|
||||
// Settings
|
||||
|
||||
export const tableViewAtom = atomWithStorage<boolean>("table-view", false);
|
||||
|
||||
export const fontSizeAtom = atomWithStorage(
|
||||
"font-size",
|
||||
Number.parseInt(document.documentElement.style.fontSize) || 100,
|
||||
);
|
||||
|
||||
export const moveNotationTypeAtom = atomWithStorage<"letters" | "symbols">("letters", "symbols");
|
||||
export const moveMethodAtom = atomWithStorage<"drag" | "select" | "both">("move-method", "both");
|
||||
export const spellCheckAtom = atomWithStorage<boolean>("spell-check", false);
|
||||
export const moveInputAtom = atomWithStorage<boolean>("move-input", false);
|
||||
export const showDestsAtom = atomWithStorage<boolean>("show-dests", true);
|
||||
export const moveHighlightAtom = atomWithStorage<boolean>("move-highlight", true);
|
||||
export const snapArrowsAtom = atomWithStorage<boolean>("snap-dests", true);
|
||||
export const showArrowsAtom = atomWithStorage<boolean>("show-arrows", true);
|
||||
export const showConsecutiveArrowsAtom = atomWithStorage<boolean>("show-consecutive-arrows", false);
|
||||
export const showVariationArrowsAtom = atomWithStorage<boolean>("show-variation-arrows", false);
|
||||
export const eraseDrawablesOnClickAtom = atomWithStorage<boolean>(
|
||||
"erase-drawables-on-click",
|
||||
false,
|
||||
);
|
||||
export const autoPromoteAtom = atomWithStorage<boolean>("auto-promote", true);
|
||||
export const autoSaveAtom = atomWithStorage<boolean>("auto-save", true);
|
||||
export const previewBoardOnHoverAtom = atomWithStorage<boolean>("preview-board-on-hover", true);
|
||||
export const flipBoardAfterMoveAtom = atomWithStorage<boolean>("flip-board-after-move", true);
|
||||
export const enableBoardScrollAtom = atomWithStorage<boolean>("board-scroll", true);
|
||||
export const materialDisplayAtom = atomWithStorage<"diff" | "all">("material-display", "diff");
|
||||
export const forcedEnPassantAtom = atomWithStorage<boolean>("forced-ep", false);
|
||||
export const showCoordinatesAtom = atomWithStorage<"no" | "edge" | "all">(
|
||||
"show-coordinates-v2",
|
||||
"no",
|
||||
undefined,
|
||||
{ getOnInit: true },
|
||||
);
|
||||
export const soundCollectionAtom = atomWithStorage<string>(
|
||||
"sound-collection",
|
||||
"standard",
|
||||
undefined,
|
||||
{
|
||||
getOnInit: true,
|
||||
},
|
||||
);
|
||||
|
||||
export const soundVolumeAtom = atomWithStorage<number>("sound-volume", 0.8, undefined, {
|
||||
getOnInit: true,
|
||||
});
|
||||
|
||||
export const pieceSetAtom = atomWithStorage<string>("piece-set", "staunty");
|
||||
export const boardImageAtom = atomWithStorage<string>("board-image", "gray.svg");
|
||||
export const primaryColorAtom = atomWithStorage<MantineColor>("mantine-primary-color", "blue");
|
||||
export const sessionsAtom = atomWithStorage<Session[]>("sessions", []);
|
||||
export const nativeBarAtom = atomWithStorage<boolean>("native-bar", false);
|
||||
export const telemetryEnabledAtom = atomWithStorage<boolean>("telemetry-enabled", true, undefined, {
|
||||
getOnInit: true,
|
||||
});
|
||||
|
||||
// Recent Files
|
||||
|
||||
export type RecentFile = {
|
||||
name: string;
|
||||
path: string;
|
||||
type: "game" | "repertoire" | "tournament" | "puzzle" | "other";
|
||||
lastOpened: number;
|
||||
};
|
||||
|
||||
const MAX_RECENT_FILES = 10;
|
||||
|
||||
export const recentFilesAtom = atomWithStorage<RecentFile[]>("recent-files", []);
|
||||
|
||||
export const addRecentFileAtom = atom(null, (get, set, file: Omit<RecentFile, "lastOpened">) => {
|
||||
const current = get(recentFilesAtom);
|
||||
const filtered = current.filter((f) => f.path !== file.path);
|
||||
const updated = [{ ...file, lastOpened: Date.now() }, ...filtered].slice(0, MAX_RECENT_FILES);
|
||||
set(recentFilesAtom, updated);
|
||||
});
|
||||
|
||||
// Database
|
||||
|
||||
export const referenceDbAtom = atomWithStorage<string | null>("reference-database", null);
|
||||
|
||||
export const selectedPuzzleDbAtom = atomWithStorage<string | null>("puzzle-db", null);
|
||||
|
||||
export type DatabaseConversionState = {
|
||||
inProgress: boolean;
|
||||
totalGames: number;
|
||||
elapsedSeconds: number;
|
||||
targetDatabasePath: string | null;
|
||||
targetDatabaseTitle: string | null;
|
||||
sourceFileName: string | null;
|
||||
};
|
||||
|
||||
export const databaseConversionStateAtom = atomWithStorage<DatabaseConversionState>(
|
||||
"database-conversion-state",
|
||||
{
|
||||
inProgress: false,
|
||||
totalGames: 0,
|
||||
elapsedSeconds: 0,
|
||||
targetDatabasePath: null,
|
||||
targetDatabaseTitle: null,
|
||||
sourceFileName: null,
|
||||
},
|
||||
createJSONStorage(() => sessionStorage),
|
||||
);
|
||||
|
||||
export const selectedDatabaseAtom = atomWithStorage<SuccessDatabaseInfo | null>(
|
||||
"database-view",
|
||||
null,
|
||||
createJSONStorage(() => sessionStorage),
|
||||
);
|
||||
|
||||
// Game Settings
|
||||
|
||||
export type GameInputColor = "white" | "random" | "black";
|
||||
|
||||
export const gameInputColorAtom = atomWithStorage<GameInputColor>("game-input-color", "white");
|
||||
|
||||
const defaultPlayerSettings: OpponentSettings = {
|
||||
type: "human",
|
||||
name: "Player",
|
||||
timeControl: DEFAULT_TIME_CONTROL,
|
||||
timeUnit: "m",
|
||||
incrementUnit: "s",
|
||||
};
|
||||
export const gamePlayer1SettingsAtom = atomWithStorage<OpponentSettings>(
|
||||
"game-player1-settings",
|
||||
defaultPlayerSettings,
|
||||
);
|
||||
|
||||
export const gamePlayer2SettingsAtom = atomWithStorage<OpponentSettings>(
|
||||
"game-player2-settings",
|
||||
defaultPlayerSettings,
|
||||
);
|
||||
|
||||
export const gameSameTimeControlAtom = atomWithStorage<boolean>("game-same-time-control", true);
|
||||
|
||||
export const gameOpeningBookPathAtom = atomWithStorage<string | null>(
|
||||
"game-opening-book-path",
|
||||
null,
|
||||
);
|
||||
|
||||
export const gameOpeningBookEnabledAtom = atomWithStorage<boolean>(
|
||||
"game-opening-book-enabled",
|
||||
false,
|
||||
);
|
||||
|
||||
export const gameOpeningBookMaxPlyAtom = atomWithStorage<number>("game-opening-book-max-ply", 40);
|
||||
|
||||
function tabValue<T extends object | string | boolean | number | null | undefined>(
|
||||
family: AtomFamily<string, PrimitiveAtom<T>>,
|
||||
) {
|
||||
return atom(
|
||||
(get) => {
|
||||
const tab = get(currentTabAtom);
|
||||
if (!tab) throw new Error("No tab selected");
|
||||
const atom = family(tab.value);
|
||||
return get(atom);
|
||||
},
|
||||
(get, set, newValue: T | ((currentValue: T) => T)) => {
|
||||
const tab = get(currentTabAtom);
|
||||
if (!tab) throw new Error("No tab selected");
|
||||
const nextValue =
|
||||
typeof newValue === "function" ? newValue(get(tabValue(family))) : newValue;
|
||||
const atom = family(tab.value);
|
||||
set(atom, nextValue);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Puzzles
|
||||
export const hidePuzzleRatingAtom = atomWithStorage<boolean>("hide-puzzle-rating", false);
|
||||
export const progressivePuzzlesAtom = atomWithStorage<boolean>("progressive-puzzles", false);
|
||||
export const jumpToNextPuzzleAtom = atomWithStorage<boolean>("puzzle-jump-immediately", true);
|
||||
export const trackPuzzleTimeAtom = atomWithStorage<boolean>("track-puzzle-time", true);
|
||||
export const puzzleRatingRangeAtom = atomWithStorage<[number, number]>(
|
||||
"puzzle-ratings",
|
||||
[1000, 1500],
|
||||
);
|
||||
|
||||
export const puzzleThemeAtom = atomWithStorage<string | null>("puzzle-theme", null);
|
||||
|
||||
export const coverageMinGamesAtom = atomWithStorage<number>("coverage-min-games", 50);
|
||||
|
||||
export const puzzleTimerFamily = atomFamily((_tab: string) => atom<number | null>(null));
|
||||
export const currentPuzzleTimerAtom = tabValue(puzzleTimerFamily);
|
||||
|
||||
// CP / WDL
|
||||
|
||||
export const reportTypeAtom = atom<"CP" | "WDL">("CP");
|
||||
|
||||
export const scoreTypeFamily = atomFamily((_engine: string) => atom<"cp" | "wdl">("cp"));
|
||||
|
||||
// Per tab settings
|
||||
|
||||
const threatFamily = atomFamily((_tab: string) => atom(false));
|
||||
export const currentThreatAtom = tabValue(threatFamily);
|
||||
|
||||
const evalOpenFamily = atomFamily((_tab: string) => atom(true));
|
||||
export const currentEvalOpenAtom = tabValue(evalOpenFamily);
|
||||
|
||||
const evalBarDisplayFamily = atomFamily((_tab: string) => atom<"cp" | "wdl">("cp"));
|
||||
export const currentEvalBarDisplayAtom = tabValue(evalBarDisplayFamily);
|
||||
|
||||
const invisibleFamily = atomFamily((_tab: string) => atom(false));
|
||||
export const currentInvisibleAtom = tabValue(invisibleFamily);
|
||||
|
||||
const showCommentsFamily = atomFamily((_tab: string) => atom(true));
|
||||
export const currentShowCommentsAtom = tabValue(showCommentsFamily);
|
||||
|
||||
const showVariationsFamily = atomFamily((_tab: string) => atom(true));
|
||||
export const currentShowVariationsAtom = tabValue(showVariationsFamily);
|
||||
|
||||
export const tabFamily = atomFamily((_tab: string) => atom("info"));
|
||||
export const currentTabSelectedAtom = tabValue(tabFamily);
|
||||
|
||||
const reportModalOpenFamily = atomFamily((_tab: string) => atom(false));
|
||||
export const currentReportModalOpenAtom = tabValue(reportModalOpenFamily);
|
||||
|
||||
const localOptionsFamily = atomFamily((_tab: string) =>
|
||||
atom<LocalOptions>({
|
||||
path: null,
|
||||
type: "exact",
|
||||
fen: "",
|
||||
player: null,
|
||||
color: "white",
|
||||
result: "any",
|
||||
}),
|
||||
);
|
||||
export const currentLocalOptionsAtom = tabValue(localOptionsFamily);
|
||||
|
||||
export const lichessOptionsAtom = atomWithStorage<LichessGamesOptions>(
|
||||
"lichess-all-options",
|
||||
{
|
||||
ratings: [1000, 1200, 1400, 1600, 1800, 2000, 2200, 2500],
|
||||
speeds: ["bullet", "blitz", "rapid", "classical", "correspondence"],
|
||||
color: "white",
|
||||
},
|
||||
createZodStorage(lichessGamesOptionsSchema, localStorage),
|
||||
{
|
||||
getOnInit: true,
|
||||
},
|
||||
);
|
||||
|
||||
export const masterOptionsAtom = atomWithStorage<MasterGamesOptions>(
|
||||
"lichess-master-options",
|
||||
{},
|
||||
createZodStorage(masterOptionsSchema, localStorage),
|
||||
{
|
||||
getOnInit: true,
|
||||
},
|
||||
);
|
||||
|
||||
const dbTypeFamily = atomFamily((_tab: string) =>
|
||||
atom<"local" | "lch_all" | "lch_master">("local"),
|
||||
);
|
||||
export const currentDbTypeAtom = tabValue(dbTypeFamily);
|
||||
|
||||
const dbTabFamily = atomFamily((_tab: string) => atom("stats"));
|
||||
export const currentDbTabAtom = tabValue(dbTabFamily);
|
||||
|
||||
const analysisTabFamily = atomFamily((_tab: string) => atom("engines"));
|
||||
export const currentAnalysisTabAtom = tabValue(analysisTabFamily);
|
||||
|
||||
const practiceTabFamily = atomFamily((_tab: string) => atom("train"));
|
||||
export const currentPracticeTabAtom = tabValue(practiceTabFamily);
|
||||
|
||||
const expandedEnginesFamily = atomFamily((_tab: string) => atom<string[] | undefined>(undefined));
|
||||
export const currentExpandedEnginesAtom = tabValue(expandedEnginesFamily);
|
||||
|
||||
export const currentDetachedEngineAtom = atomWithStorage<string | null>("detached-engine", null);
|
||||
|
||||
const pgnOptionsFamily = atomFamily((_tab: string) =>
|
||||
atom({
|
||||
comments: true,
|
||||
glyphs: true,
|
||||
variations: true,
|
||||
extraMarkups: true,
|
||||
}),
|
||||
);
|
||||
export const currentPgnOptionsAtom = tabValue(pgnOptionsFamily);
|
||||
|
||||
const currentPuzzleFamily = atomFamily((_tab: string) => atom(0));
|
||||
export const currentPuzzleAtom = tabValue(currentPuzzleFamily);
|
||||
|
||||
// Game
|
||||
|
||||
type GameState = "settingUp" | "playing" | "gameOver";
|
||||
const gameStateFamily = atomFamily((_tab: string) => atom<GameState>("settingUp"));
|
||||
export const currentGameStateAtom = tabValue(gameStateFamily);
|
||||
|
||||
const playersFamily = atomFamily((_tab: string) =>
|
||||
atom<{
|
||||
white: OpponentSettings;
|
||||
black: OpponentSettings;
|
||||
}>({ white: {} as OpponentSettings, black: {} as OpponentSettings }),
|
||||
);
|
||||
export const currentPlayersAtom = tabValue(playersFamily);
|
||||
|
||||
const gameIdFamily = atomFamily((_tab: string) => atom<string | null>(null));
|
||||
export const currentGameIdAtom = tabValue(gameIdFamily);
|
||||
|
||||
// Practice
|
||||
|
||||
const reviewLogSchema = z
|
||||
.object({
|
||||
fen: z.string(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const practiceDataSchema = z.object({
|
||||
positions: positionSchema.array(),
|
||||
logs: reviewLogSchema.array(),
|
||||
});
|
||||
|
||||
export type PracticeData = {
|
||||
positions: Position[];
|
||||
logs: (ReviewLog & { fen: string })[];
|
||||
};
|
||||
|
||||
export const deckAtomFamily = atomFamily(
|
||||
({ file, game }: { file: string; game: number }) =>
|
||||
atomWithStorage<PracticeData>(
|
||||
`deck-${file}-${game}`,
|
||||
{
|
||||
positions: [],
|
||||
logs: [],
|
||||
},
|
||||
createZodStorage(practiceDataSchema, localStorage) as any as SyncStorage<PracticeData>, // TODO: fix types
|
||||
),
|
||||
|
||||
(a, b) => a.file === b.file && a.game === b.game,
|
||||
);
|
||||
|
||||
export type PracticePhase =
|
||||
| "idle" // Not practicing
|
||||
| "waiting" // Waiting for user to make a move
|
||||
| "correct" // Move was correct, waiting for quality rating
|
||||
| "incorrect"; // Move was incorrect, showing feedback
|
||||
|
||||
export type PracticeState = {
|
||||
phase: PracticePhase;
|
||||
currentFen?: string;
|
||||
answer?: string;
|
||||
playedMove?: string;
|
||||
timeTaken?: number;
|
||||
positionIndex?: number;
|
||||
};
|
||||
|
||||
export const practiceStateFamily = atomFamily((_tab: string) =>
|
||||
atom<PracticeState>({ phase: "idle" }),
|
||||
);
|
||||
export const practiceStateAtom = tabValue(practiceStateFamily);
|
||||
|
||||
export type PracticeSessionStats = {
|
||||
mode: "anki" | "full";
|
||||
remainingPositions: number[];
|
||||
correct: number;
|
||||
incorrect: number;
|
||||
streak: number;
|
||||
bestStreak: number;
|
||||
};
|
||||
|
||||
const practiceSessionStatsFamily = atomFamily((_tab: string) =>
|
||||
atom<PracticeSessionStats>({
|
||||
mode: "anki",
|
||||
remainingPositions: [],
|
||||
correct: 0,
|
||||
incorrect: 0,
|
||||
streak: 0,
|
||||
bestStreak: 0,
|
||||
}),
|
||||
);
|
||||
export const practiceSessionStatsAtom = tabValue(practiceSessionStatsFamily);
|
||||
|
||||
export const practiceAutoDifficultyAtom = atomWithStorage<"none" | "1" | "2" | "3" | "4">(
|
||||
"practice-auto-difficulty",
|
||||
"none",
|
||||
);
|
||||
|
||||
const practiceCardStartTimeFamily = atomFamily((_tab: string) => atom<number>(0));
|
||||
export const practiceCardStartTimeAtom = tabValue(practiceCardStartTimeFamily);
|
||||
|
||||
export const engineMovesFamily = atomFamily(
|
||||
({ tab: _tab, engine: _engine }: { tab: string; engine: string }) =>
|
||||
atom<Map<string, BestMoves[]>>(new Map()),
|
||||
(a, b) => a.tab === b.tab && a.engine === b.engine,
|
||||
);
|
||||
|
||||
export const engineProgressFamily = atomFamily(
|
||||
({ tab: _tab, engine: _engine }: { tab: string; engine: string }) => atom<number>(0),
|
||||
(a, b) => a.tab === b.tab && a.engine === b.engine,
|
||||
);
|
||||
|
||||
// returns the best moves of each engine for the current position
|
||||
export const bestMovesFamily = atomFamily(
|
||||
({ fen, gameMoves }: { fen: string; gameMoves: string[] }) =>
|
||||
atom<Map<number, { pv: string[]; winChance: number }[]>>((get) => {
|
||||
const tab = get(activeTabAtom);
|
||||
if (!tab) return new Map();
|
||||
const engines = get(enginesAtom);
|
||||
if (!engines) return new Map();
|
||||
const bestMoves = new Map<number, { pv: string[]; winChance: number }[]>();
|
||||
let n = 0;
|
||||
for (const engine of engines.filter((e) => e.loaded)) {
|
||||
const engineMoves = get(engineMovesFamily({ tab, engine: engine.id }));
|
||||
const [pos] = positionFromFen(fen);
|
||||
let finalFen = INITIAL_FEN;
|
||||
if (pos) {
|
||||
for (const move of gameMoves) {
|
||||
const m = parseUci(move);
|
||||
pos.play(m!);
|
||||
}
|
||||
finalFen = makeFen(pos.toSetup());
|
||||
}
|
||||
const moves =
|
||||
engineMoves.get(`${swapMove(finalFen)}:`) ||
|
||||
engineMoves.get(`${fen}:${gameMoves.join(",")}`);
|
||||
if (moves && moves.length > 0) {
|
||||
const bestWinChange = getWinChance(
|
||||
normalizeScore(moves[0].score.value, pos?.turn || "white"),
|
||||
);
|
||||
bestMoves.set(
|
||||
n,
|
||||
moves.reduce<{ pv: string[]; winChance: number }[]>((acc, m) => {
|
||||
const winChance = getWinChance(
|
||||
normalizeScore(m.score.value, pos?.turn || "white"),
|
||||
);
|
||||
if (bestWinChange - winChance < 10) {
|
||||
acc.push({ pv: m.uciMoves, winChance });
|
||||
}
|
||||
return acc;
|
||||
}, []),
|
||||
);
|
||||
}
|
||||
n++;
|
||||
}
|
||||
return bestMoves;
|
||||
}),
|
||||
(a, b) => a.fen === b.fen && equal(a.gameMoves, b.gameMoves),
|
||||
);
|
||||
|
||||
export const firstEngineWithLinesFamily = atomFamily(
|
||||
({ fen, gameMoves }: { fen: string; gameMoves: string[] }) =>
|
||||
atom<string | null>((get) => {
|
||||
const tab = get(activeTabAtom);
|
||||
if (!tab) return null;
|
||||
const engines = get(enginesAtom);
|
||||
if (!engines) return null;
|
||||
|
||||
const [pos] = positionFromFen(fen);
|
||||
let finalFen = INITIAL_FEN;
|
||||
if (pos) {
|
||||
for (const move of gameMoves) {
|
||||
const m = parseUci(move);
|
||||
if (m) pos.play(m);
|
||||
}
|
||||
finalFen = makeFen(pos.toSetup());
|
||||
}
|
||||
|
||||
for (const engine of engines.filter((e) => e.loaded)) {
|
||||
const engineMoves = get(engineMovesFamily({ tab, engine: engine.id }));
|
||||
const moves =
|
||||
engineMoves.get(`${swapMove(finalFen)}:`) ||
|
||||
engineMoves.get(`${fen}:${gameMoves.join(",")}`);
|
||||
|
||||
if (moves && moves.length > 0) {
|
||||
return engine.id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
(a, b) => a.fen === b.fen && equal(a.gameMoves, b.gameMoves),
|
||||
);
|
||||
|
||||
export const tabEngineSettingsFamily = atomFamily(
|
||||
({
|
||||
tab: _tab,
|
||||
engineId: _engineId,
|
||||
defaultSettings,
|
||||
defaultGo,
|
||||
}: {
|
||||
tab: string;
|
||||
engineId: string;
|
||||
defaultSettings?: EngineSettings;
|
||||
defaultGo?: GoMode;
|
||||
}) => {
|
||||
return atom<{
|
||||
enabled: boolean;
|
||||
settings: EngineSettings;
|
||||
go: GoMode;
|
||||
synced: boolean;
|
||||
}>({
|
||||
enabled: false,
|
||||
settings: defaultSettings || [],
|
||||
go: defaultGo || { t: "Infinite" },
|
||||
synced: true,
|
||||
});
|
||||
},
|
||||
(a, b) => a.tab === b.tab && a.engineId === b.engineId,
|
||||
);
|
||||
|
||||
export const allEnabledAtom = atom((get) => {
|
||||
const engines = get(enginesAtom);
|
||||
if (!engines) return false;
|
||||
|
||||
const v = engines
|
||||
.filter((e) => e.loaded)
|
||||
.every((engine) => {
|
||||
const atom = tabEngineSettingsFamily({
|
||||
tab: get(activeTabAtom)!,
|
||||
engineId: engine.id,
|
||||
defaultSettings: engine.type === "local" ? engine.settings || [] : undefined,
|
||||
defaultGo: engine.go ?? undefined,
|
||||
});
|
||||
return get(atom).enabled;
|
||||
});
|
||||
|
||||
return v;
|
||||
});
|
||||
|
||||
export const enableAllAtom = atom(null, (get, set, value: boolean) => {
|
||||
const engines = get(enginesAtom);
|
||||
if (!engines) return;
|
||||
|
||||
for (const engine of engines.filter((e) => e.loaded)) {
|
||||
const atom = tabEngineSettingsFamily({
|
||||
tab: get(activeTabAtom)!,
|
||||
engineId: engine.id,
|
||||
defaultSettings: engine.type === "local" ? engine.settings || [] : undefined,
|
||||
defaultGo: engine.go ?? undefined,
|
||||
});
|
||||
set(atom, { ...get(atom), enabled: value });
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user