Upstream franciscoBSalgueiro/en-croissant state of the touched files before 512cbd9d53

This commit is contained in:
upstream committed 2026-09-26 09:46:11 +02:00
1 parent 01c5621cdb
commit 654f884a5c
3 files changed
+1728

No files matched your search

+815
View File
@@ -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;
+218
View File
@@ -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
);
});
+695
View File
@@ -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 });
}
});