From 654f884a5c66a1e95266d0debf011073460e3cd2 Mon Sep 17 00:00:00 2001 From: upstream Date: Sat, 26 Sep 2026 09:46:11 +0200 Subject: [PATCH] Upstream franciscoBSalgueiro/en-croissant state of the touched files before 512cbd9d53 --- src/components/boards/BoardGame.tsx | 815 +++++++++++++++++++++ src/components/common/CompleteMoveCell.tsx | 218 ++++++ src/state/atoms.ts | 695 ++++++++++++++++++ 3 files changed, 1728 insertions(+) create mode 100644 src/components/boards/BoardGame.tsx create mode 100644 src/components/common/CompleteMoveCell.tsx create mode 100644 src/state/atoms.ts diff --git a/src/components/boards/BoardGame.tsx b/src/components/boards/BoardGame.tsx new file mode 100644 index 0000000..b178a70 --- /dev/null +++ b/src/components/boards/BoardGame.tsx @@ -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(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(null); + const [gameState, setGameState] = useAtom(currentGameStateAtom); + const [players, setPlayers] = useAtom(currentPlayersAtom); + + const [whiteTime, setWhiteTime] = useState(null); + const [blackTime, setBlackTime] = useState(null); + const [gameId, setGameId] = useAtom(currentGameIdAtom); + + const [logsOpened, toggleLogsOpened] = useToggle(); + const [logsColor, setLogsColor] = useState<"white" | "black">("white"); + const [engineLogs, setEngineLogs] = useState([]); + 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 = { + 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 | 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 ( + <> + + + + + + {logsOpened ? ( + + {players.white.type === "engine" && players.black.type === "engine" ? ( + setLogsColor(value as "white" | "black")} + data={[ + { value: "white", label: "White" }, + { value: "black", label: "Black" }, + ]} + /> + ) : ( +
+ )} + toggleLogsOpened()}> + + + + } + /> + ) : ( + <> + {gameState === "settingUp" && ( + + + + + + {match(inputColor) + .with("white", () => "White") + .with("random", () => "Random") + .with("black", () => "Black") + .exhaustive()} + + + + + + {match(inputColor) + .with("white", () => "Black") + .with("random", () => "Random") + .with("black", () => "White") + .exhaustive()} + + + + + + + + + + + + + { + const checked = e.target.checked; + setSameTimeControl(checked); + if (checked) { + setPlayer2Settings((prev) => ({ + ...prev, + timeControl: player1Settings.timeControl, + timeUnit: player1Settings.timeUnit, + incrementUnit: player1Settings.incrementUnit, + })); + } + }} + /> + + + + setOpeningBookEnabled(e.currentTarget.checked)} + /> + + {openingBookEnabled && ( + <> + + {openingBookPath?.includes(".bin") && ( + { + if (typeof value === "number" && Number.isFinite(value)) { + setOpeningBookMaxPly(Math.max(1, Math.trunc(value))); + } + }} + /> + )} + + )} + + + + + + + + + )} + {(gameState === "playing" || gameState === "gameOver") && ( + + + + + + {gameState === "playing" && ( + + )} + {gameState === "gameOver" && ( + + )} + + + {hasEngine && ( + + )} + + + )} + + )} + + + + {gameState === "settingUp" && editingMode ? ( + + ) : ( + + + } + /> + + + )} + + + ); +} + +export default BoardGame; diff --git a/src/components/common/CompleteMoveCell.tsx b/src/components/common/CompleteMoveCell.tsx new file mode 100644 index 0000000..395d90c --- /dev/null +++ b/src/components/common/CompleteMoveCell.tsx @@ -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>(); + +function getTranspositionMap(root: TreeNode) { + if (transpositionCache.has(root)) { + return transpositionCache.get(root)!; + } + + const map = new Map(); + 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; + 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 ( + <> + + {hasNumber && `${moveNumber.toString()}${isWhite ? "." : "..."}`} + {move && ( + + + goToMove(movePath)} + onContextMenu={(e: React.MouseEvent) => { + setOpen((v) => !v); + e.preventDefault(); + }} + fullWidth={tableLayout} + rightAccessory={ + tableLayout && scoreText ? ( + + {scoreText} + + ) : undefined + } + /> + + + + + {tabFile?.metadata.type === "repertoire" && ( + } + onClick={() => setStart(movePath)} + > + {t("Menu.MarkAsStart")} + + )} + } + onClick={() => promoteToMainline(movePath)} + > + {t("Menu.PromoteToMainLine")} + + + } + onClick={() => promoteVariation(movePath)} + > + {t("Menu.PromoteVariation")} + + + } + onClick={() => copyVariationPgn(movePath)} + > + {t("Menu.CopyVariationPGN")} + + + } + onClick={() => deleteMove(movePath)} + > + {t("Menu.DeleteMove")} + + + + + )} + {transpositions.length > 0 && ( + + goToMove(transpositions[0])}> + + + + )} + + {showComments && !tableLayout && 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 + ); +}); diff --git a/src/state/atoms.ts b/src/state/atoms.ts new file mode 100644 index 0000000..3e63d84 --- /dev/null +++ b/src/state/atoms.ts @@ -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 = (itemSchema: z.ZodType) => { + 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; +}; + +// Tabs + +const firstTab: Tab = { + name: "Tab.NewTab", + value: genID(), + type: "new", + gameOrigin: { + kind: "none", + }, +}; + +export const tabsAtom = atomWithStorage( + "tabs", + [firstTab], + createZodStorage(z.array(tabSchema), sessionStorage), +); + +export const activeTabAtom = atomWithStorage( + "activeTab", + firstTab.value, + createJSONStorage(() => sessionStorage), +); + +export const expandedDirectoriesAtom = atomWithStorage( + "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("document-dir", "", undefined, { + getOnInit: true, +}); +export const storedDatabasesDirAtom = atomWithStorage("databases-dir", "", undefined, { + getOnInit: true, +}); +export const storedEnginesDirAtom = atomWithStorage("engines-dir", "", undefined, { + getOnInit: true, +}); +export const storedPuzzlesDirAtom = atomWithStorage("puzzles-dir", "", undefined, { + getOnInit: true, +}); + +async function ensureParentDir(path: string): Promise { + 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 { + 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( + "engines/engines.json", + [], + createAsyncZodStorage(zodArray(engineSchema), enginesFileStorage) as AsyncStorage, + ), +); + +// Settings + +export const tableViewAtom = atomWithStorage("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("spell-check", false); +export const moveInputAtom = atomWithStorage("move-input", false); +export const showDestsAtom = atomWithStorage("show-dests", true); +export const moveHighlightAtom = atomWithStorage("move-highlight", true); +export const snapArrowsAtom = atomWithStorage("snap-dests", true); +export const showArrowsAtom = atomWithStorage("show-arrows", true); +export const showConsecutiveArrowsAtom = atomWithStorage("show-consecutive-arrows", false); +export const showVariationArrowsAtom = atomWithStorage("show-variation-arrows", false); +export const eraseDrawablesOnClickAtom = atomWithStorage( + "erase-drawables-on-click", + false, +); +export const autoPromoteAtom = atomWithStorage("auto-promote", true); +export const autoSaveAtom = atomWithStorage("auto-save", true); +export const previewBoardOnHoverAtom = atomWithStorage("preview-board-on-hover", true); +export const flipBoardAfterMoveAtom = atomWithStorage("flip-board-after-move", true); +export const enableBoardScrollAtom = atomWithStorage("board-scroll", true); +export const materialDisplayAtom = atomWithStorage<"diff" | "all">("material-display", "diff"); +export const forcedEnPassantAtom = atomWithStorage("forced-ep", false); +export const showCoordinatesAtom = atomWithStorage<"no" | "edge" | "all">( + "show-coordinates-v2", + "no", + undefined, + { getOnInit: true }, +); +export const soundCollectionAtom = atomWithStorage( + "sound-collection", + "standard", + undefined, + { + getOnInit: true, + }, +); + +export const soundVolumeAtom = atomWithStorage("sound-volume", 0.8, undefined, { + getOnInit: true, +}); + +export const pieceSetAtom = atomWithStorage("piece-set", "staunty"); +export const boardImageAtom = atomWithStorage("board-image", "gray.svg"); +export const primaryColorAtom = atomWithStorage("mantine-primary-color", "blue"); +export const sessionsAtom = atomWithStorage("sessions", []); +export const nativeBarAtom = atomWithStorage("native-bar", false); +export const telemetryEnabledAtom = atomWithStorage("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("recent-files", []); + +export const addRecentFileAtom = atom(null, (get, set, file: Omit) => { + 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("reference-database", null); + +export const selectedPuzzleDbAtom = atomWithStorage("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( + "database-conversion-state", + { + inProgress: false, + totalGames: 0, + elapsedSeconds: 0, + targetDatabasePath: null, + targetDatabaseTitle: null, + sourceFileName: null, + }, + createJSONStorage(() => sessionStorage), +); + +export const selectedDatabaseAtom = atomWithStorage( + "database-view", + null, + createJSONStorage(() => sessionStorage), +); + +// Game Settings + +export type GameInputColor = "white" | "random" | "black"; + +export const gameInputColorAtom = atomWithStorage("game-input-color", "white"); + +const defaultPlayerSettings: OpponentSettings = { + type: "human", + name: "Player", + timeControl: DEFAULT_TIME_CONTROL, + timeUnit: "m", + incrementUnit: "s", +}; +export const gamePlayer1SettingsAtom = atomWithStorage( + "game-player1-settings", + defaultPlayerSettings, +); + +export const gamePlayer2SettingsAtom = atomWithStorage( + "game-player2-settings", + defaultPlayerSettings, +); + +export const gameSameTimeControlAtom = atomWithStorage("game-same-time-control", true); + +export const gameOpeningBookPathAtom = atomWithStorage( + "game-opening-book-path", + null, +); + +export const gameOpeningBookEnabledAtom = atomWithStorage( + "game-opening-book-enabled", + false, +); + +export const gameOpeningBookMaxPlyAtom = atomWithStorage("game-opening-book-max-ply", 40); + +function tabValue( + family: AtomFamily>, +) { + 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("hide-puzzle-rating", false); +export const progressivePuzzlesAtom = atomWithStorage("progressive-puzzles", false); +export const jumpToNextPuzzleAtom = atomWithStorage("puzzle-jump-immediately", true); +export const trackPuzzleTimeAtom = atomWithStorage("track-puzzle-time", true); +export const puzzleRatingRangeAtom = atomWithStorage<[number, number]>( + "puzzle-ratings", + [1000, 1500], +); + +export const puzzleThemeAtom = atomWithStorage("puzzle-theme", null); + +export const coverageMinGamesAtom = atomWithStorage("coverage-min-games", 50); + +export const puzzleTimerFamily = atomFamily((_tab: string) => atom(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({ + path: null, + type: "exact", + fen: "", + player: null, + color: "white", + result: "any", + }), +); +export const currentLocalOptionsAtom = tabValue(localOptionsFamily); + +export const lichessOptionsAtom = atomWithStorage( + "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( + "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(undefined)); +export const currentExpandedEnginesAtom = tabValue(expandedEnginesFamily); + +export const currentDetachedEngineAtom = atomWithStorage("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("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(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( + `deck-${file}-${game}`, + { + positions: [], + logs: [], + }, + createZodStorage(practiceDataSchema, localStorage) as any as SyncStorage, // 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({ 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({ + 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(0)); +export const practiceCardStartTimeAtom = tabValue(practiceCardStartTimeFamily); + +export const engineMovesFamily = atomFamily( + ({ tab: _tab, engine: _engine }: { tab: string; engine: string }) => + atom>(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(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>((get) => { + const tab = get(activeTabAtom); + if (!tab) return new Map(); + const engines = get(enginesAtom); + if (!engines) return new Map(); + const bestMoves = new Map(); + 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((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 }); + } +});