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