diff --git a/src/components/Common/Header.tsx b/src/components/Common/Header.tsx index fc8911da..3bb778d7 100644 --- a/src/components/Common/Header.tsx +++ b/src/components/Common/Header.tsx @@ -148,7 +148,7 @@ export const Header: React.FC = () => { />

Maia Chess

-
+
setShowPlayDropdown(true)} @@ -233,6 +233,16 @@ export const Header: React.FC = () => { BROADCASTS )} + + CANDIDATES +
setShowMoreDropdown(true)} @@ -415,6 +425,16 @@ export const Header: React.FC = () => { Broadcasts + + Candidates + Leaderboard diff --git a/src/components/Common/PlaySetupModal.tsx b/src/components/Common/PlaySetupModal.tsx index 5c518b24..0d394594 100644 --- a/src/components/Common/PlaySetupModal.tsx +++ b/src/components/Common/PlaySetupModal.tsx @@ -82,11 +82,35 @@ interface Props { sampleMoves?: boolean simulateMaiaTime?: boolean startFen?: string + returnTo?: string + challengeId?: string + forcedPlayerColor?: Color + modalTitle?: string + modalSubtitle?: string } export const PlaySetupModal: React.FC = (props: Props) => { const { setPlaySetupModalProps } = useContext(ModalContext) - const { push } = useRouter() + const router = useRouter() + const { push } = router + + const dismissModal = useCallback(() => { + setPlaySetupModalProps(undefined) + + if ( + props.returnTo && + router.pathname === '/play' && + router.asPath !== props.returnTo + ) { + push(props.returnTo) + } + }, [ + props.returnTo, + push, + router.asPath, + router.pathname, + setPlaySetupModalProps, + ]) const [timeControl, setTimeControl] = useState( props.timeControl || TimeControlOptions[0], @@ -118,9 +142,22 @@ export const PlaySetupModal: React.FC = (props: Props) => { const [fen, setFen] = useState( props.startFen ? props.startFen : undefined, ) + const forcedPlayerColor = props.forcedPlayerColor + const colorSelectionLocked = forcedPlayerColor !== undefined + const positionLocked = forcedPlayerColor !== undefined const [openMoreOptions, setMoreOptionsOpen] = useState(true) const compactHandBrainLayout = props.playType === 'handAndBrain' + const modalTitle = + props.modalTitle || + (props.playType == 'againstMaia' + ? 'Play Against Maia' + : 'Play Hand and Brain') + const modalSubtitle = + props.modalSubtitle || + (props.playType == 'againstMaia' + ? 'Configure your game settings and choose your side' + : 'Team up with Maia in Hand and Brain chess') const handlePresetSelect = useCallback((preset: TimeControl) => { setTimeControl(preset) @@ -156,7 +193,16 @@ export const PlaySetupModal: React.FC = (props: Props) => { const start = useCallback( (color: Color | undefined) => { + if ( + forcedPlayerColor && + color !== undefined && + color !== forcedPlayerColor + ) { + return + } + const player = color ?? ['white', 'black'][Math.floor(Math.random() * 2)] + const resolvedPlayer = forcedPlayerColor ?? player if (fen && !new Chess().validateFen(fen).valid) { toast.error('Invalid Starting FEN provided') @@ -169,20 +215,25 @@ export const PlaySetupModal: React.FC = (props: Props) => { push({ pathname: '/play/maia', query: { - player: player, + player: resolvedPlayer, //maiaPartnerVersion: maiaPartnerVersion, maiaVersion: maiaVersion, timeControl: timeControl, sampleMoves: sampleMoves, simulateMaiaTime: simulateMaiaTime, startFen: fen, + returnTo: props.returnTo, + challengeId: props.challengeId, + forcedColor: forcedPlayerColor, + modalTitle: props.modalTitle, + modalSubtitle: props.modalSubtitle, }, }) } else { push({ pathname: '/play/hb', query: { - player: player, + player: resolvedPlayer, maiaPartnerVersion: maiaPartnerVersion, maiaVersion: maiaVersion, timeControl: timeControl, @@ -204,13 +255,18 @@ export const PlaySetupModal: React.FC = (props: Props) => { sampleMoves, simulateMaiaTime, fen, + forcedPlayerColor, isBrain, + props.challengeId, + props.modalSubtitle, + props.modalTitle, + props.returnTo, ], ) return ( - setPlaySetupModalProps(undefined)}> + = (props: Props) => { @@ -242,16 +298,8 @@ export const PlaySetupModal: React.FC = (props: Props) => { compactHandBrainLayout ? 'px-4 py-3' : 'p-4' }`} > -

- {props.playType == 'againstMaia' - ? 'Play Against Maia' - : 'Play Hand and Brain'} -

-

- {props.playType == 'againstMaia' - ? 'Configure your game settings and choose your side' - : 'Team up with Maia in Hand and Brain chess'} -

+

{modalTitle}

+

{modalSubtitle}

{/* Settings Section */} @@ -297,7 +345,7 @@ export const PlaySetupModal: React.FC = (props: Props) => { setMaiaVersion(e.target.value)} > {maiaOptions.map((maia) => ( @@ -484,11 +532,18 @@ export const PlaySetupModal: React.FC = (props: Props) => { type="text" value={fen} placeholder="rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" + readOnly={positionLocked} onChange={(e) => setFen(e.target.value)} - className="w-full rounded border border-glass-border bg-glass px-3 py-2 font-mono text-xs text-white/90 placeholder-white/60 focus:outline-none" + className={`w-full rounded border border-glass-border px-3 py-2 font-mono text-xs placeholder-white/60 focus:outline-none ${ + positionLocked + ? 'bg-glass text-white/90' + : 'bg-glass text-white/90' + }`} />

- Enter a valid FEN string to start from a specific position + {positionLocked + ? 'This challenge uses a fixed starting position.' + : 'Enter a valid FEN string to start from a specific position'}

)} @@ -508,11 +563,22 @@ export const PlaySetupModal: React.FC = (props: Props) => { > Choose your color:

+ {colorSelectionLocked ? ( +

+ This challenge starts with{' '} + {forcedPlayerColor === 'white' ? 'White' : 'Black'} to move. +

+ ) : null}
) : null} + {returnTo ? ( + + ) : null}
) : ( <> diff --git a/src/components/Puzzles/Feedback.tsx b/src/components/Puzzles/Feedback.tsx index 95223d7d..d7cdec94 100644 --- a/src/components/Puzzles/Feedback.tsx +++ b/src/components/Puzzles/Feedback.tsx @@ -1,7 +1,6 @@ import { Chess } from 'chess.ts' import { useMemo, Dispatch, SetStateAction } from 'react' -import { Markdown } from 'src/components' import { useTrainingController } from 'src/hooks' import { PuzzleGame, Status } from 'src/types/puzzle' @@ -14,6 +13,7 @@ interface Props { controller: ReturnType lastAttemptedMove: string | null setLastAttemptedMove: Dispatch> + solutionMoveSan: string | null embedded?: boolean } @@ -26,81 +26,91 @@ export const Feedback: React.FC = ({ controller: controller, lastAttemptedMove, setLastAttemptedMove, + solutionMoveSan, embedded = false, }: Props) => { - const { targetIndex } = game - const turn = new Chess(controller.gameTree.getLastMainlineNode().fen).turn() === 'w' ? 'white' : 'black' - const archivedContent = ` - ##### PUZZLE COMPLETED - You already solved this puzzle. Use the boxes on the left to navigate to another puzzle. -` + const content = useMemo(() => { + if (status === 'archived') { + return { + titlePrefix: null, + title: 'You already solved this puzzle.', + detail: 'Choose another puzzle from the history list.', + titleClass: 'text-primary', + accentPrefixClass: '', + } + } - const defaultContent = ` - ##### YOUR TURN - Find the best move for **${turn}**! - ` - const incorrectContent = ` - ##### ${lastAttemptedMove || 'Move'} is incorrect - Try again or give up to analyze the board and see the best move. - ` + if (status === 'forfeit') { + return { + titlePrefix: null, + title: `${solutionMoveSan || 'That move'} is the best move.`, + detail: 'Explore the position or try the next puzzle.', + titleClass: 'text-primary', + accentPrefixClass: '', + } + } - const correctContent = ` - ##### Correct! ${lastAttemptedMove || 'Move'} is the best move. - You can now explore and analyze the position by making moves, or train on another position. - ` + if (status === 'correct') { + return { + titlePrefix: 'Correct!', + title: ` ${lastAttemptedMove || 'That move'} is the best move.`, + detail: 'Explore the position or try the next puzzle.', + titleClass: 'text-primary', + accentPrefixClass: 'text-green-400', + } + } - const gaveUpContent = ` - ##### Explore the position - Explore the current position by making moves or train on another position.` + if (status === 'incorrect') { + return { + titlePrefix: 'Incorrect.', + title: ` ${lastAttemptedMove || 'That move'} is not the best move.`, + detail: 'Try again or give up to unlock analysis.', + titleClass: 'text-primary', + accentPrefixClass: 'text-human-2', + } + } - const content = useMemo(() => { - if (status === 'archived') { - return archivedContent - } else if (status === 'forfeit') { - return gaveUpContent - } else if (status === 'correct') { - return correctContent - } else if (status === 'incorrect') { - return incorrectContent - } else { - return defaultContent + return { + titlePrefix: null, + title: `Find the best move for ${turn}.`, + detail: 'Give up if you want to reveal the answer and analyze it.', + titleClass: 'text-primary', + accentPrefixClass: '', } - }, [defaultContent, incorrectContent, correctContent, status, targetIndex]) + }, [lastAttemptedMove, solutionMoveSan, status, turn]) return (
-
- {content.trim()} - {(status === 'forfeit' || status === 'correct') && ( -
-
- - arrow_outward - - Most Human Move -
-
- - arrow_outward +
+
+

+ {content.titlePrefix ? ( + + {content.titlePrefix} - Best Engine Move -

-
- )} + ) : null} + {content.title} + +

+ {content.detail} +

+
-
+
{status !== 'archived' && ( <> {status === 'incorrect' && ( @@ -110,7 +120,7 @@ export const Feedback: React.FC = ({ setLastAttemptedMove(null) controller.reset() }} - className="flex w-full justify-center rounded-sm bg-engine-3 py-1.5 text-sm font-medium text-primary transition duration-300 hover:bg-engine-4 disabled:bg-backdrop disabled:text-secondary" + className="flex w-full justify-center rounded-md border border-engine-4/40 bg-engine-4/15 py-2 text-sm font-medium text-primary transition duration-300 hover:bg-engine-4/25 disabled:bg-backdrop disabled:text-secondary" > Try Again @@ -118,7 +128,7 @@ export const Feedback: React.FC = ({ {status !== 'forfeit' && status !== 'correct' && ( @@ -128,7 +138,7 @@ export const Feedback: React.FC = ({ onClick={async () => { await getNewGame() }} - className="flex w-full justify-center rounded-sm bg-human-3 py-1.5 text-sm font-medium text-primary transition duration-300 hover:bg-human-4 disabled:bg-backdrop disabled:text-secondary" + className="flex w-full justify-center rounded-md bg-human-3 py-2 text-sm font-medium text-primary transition duration-300 hover:bg-human-4 disabled:bg-backdrop disabled:text-secondary" > Next Puzzle diff --git a/src/constants/candidates.ts b/src/constants/candidates.ts new file mode 100644 index 00000000..f81e3f4c --- /dev/null +++ b/src/constants/candidates.ts @@ -0,0 +1,121 @@ +import type { PositionLinkOptions } from 'src/lib/positionLinks' + +export interface CandidateStoryline { + title: string + description: string + icon: string +} + +export interface CandidateIdea { + title: string + description: string + icon: string +} + +export interface CandidatePosition extends PositionLinkOptions { + id: string + title: string + subtitle: string + summary: string + tag: string + accent: 'amber' | 'red' | 'blue' + broadcastHref?: string +} + +export const CANDIDATES_HERO = { + eyebrow: 'Candidates HQ', + title: 'One page for live moments, instant analysis, and Maia challenges.', + description: + 'As the tournament starts, this page becomes the fast path from a sharp position to deep analysis or a playable Maia challenge.', +} + +export const CANDIDATES_STORYLINES: CandidateStoryline[] = [ + { + title: 'Interesting positions first', + description: + 'Every featured moment can jump straight into a dedicated analysis view instead of forcing users to scrub through move lists.', + icon: 'center_focus_strong', + }, + { + title: 'Turn any moment into a challenge', + description: + 'Linked drills start from the exact board state so visitors can immediately test whether they can convert or defend it better than the players did.', + icon: 'swords', + }, + { + title: 'Broadcast, analysis, and drills stay connected', + description: + 'Each card can carry the live round link too, so people can move between the event feed and the interactive tools without losing the thread.', + icon: 'hub', + }, +] + +export const CANDIDATES_EXTRA_IDEAS: CandidateIdea[] = [ + { + title: 'Maia Disagreement Meter', + description: + 'Flag moments where Maia strongly prefers a human move that differs from Stockfish, then sort those by surprise value.', + icon: 'psychology', + }, + { + title: 'Conversion Tests', + description: + 'Collect winning positions that still require technique and let visitors see whether they can actually finish the job against Maia.', + icon: 'military_tech', + }, + { + title: 'Round Recap Shelf', + description: + 'Keep one short note and one featured board from each round so the page becomes an archive instead of a disposable live feed.', + icon: 'history', + }, +] + +// Drop live tournament moments here as PGN or FEN during the event. +export const CANDIDATES_FEATURED_POSITIONS: CandidatePosition[] = [] + +// Warm-up cards keep the page useful before round one begins. +export const CANDIDATES_WARMUP_POSITIONS: CandidatePosition[] = [ + { + id: 'warmup-pressure-center', + title: 'Rd1 Challenge 1: Can you convert Sindarov vs. Esipenko?', + subtitle: + 'Esipenko (Black) just blundered with 31. ...Qc6??. Can you convert the win like Sindarov did?', + summary: + 'Sindarov has the win in hand after 31. ...Qc6??. White to move and convert.', + tag: 'Warm-up', + accent: 'red', + fen: '5rk1/6p1/Rbq4p/1p1pRp1Q/2pn1P1P/1P4P1/5PK1/3B4 w - - 1 32', + playerColor: 'white', + maiaVersion: 'maia_kdd_1900', + targetMoveNumber: 8, + }, + { + id: 'warmup-endgame-squeeze', + title: 'Rd1 Challenge 2: Win like Pragg', + subtitle: + "Praggnanandhaa took advantage of Giri's mistake to take home the full point. Can you do the same?", + summary: + "Praggnanandhaa converted after Giri's mistake. White to move and win.", + tag: 'Warm-up', + accent: 'amber', + fen: '8/1p1k4/p4nn1/2p3Nr/P3Rp1P/2PP4/6P1/4B1K1 w - - 3 37', + playerColor: 'white', + maiaVersion: 'maia_kdd_1700', + targetMoveNumber: 10, + }, + { + id: 'warmup-take-down-hikaru', + title: 'Rd1 Challenge 3: Take down Hikaru', + subtitle: + 'Fabi has built up a nice advantage against Hikaru, but how do you break though?', + summary: + 'Caruana is pressing against Nakamura. White to move and finish the attack.', + tag: 'Warm-up', + accent: 'blue', + fen: 'q4k2/3B2p1/1p3b1p/p6P/P1Pp2Q1/3P2P1/5P2/6K1 w - - 5 55', + playerColor: 'white', + maiaVersion: 'maia_kdd_1900', + targetMoveNumber: 8, + }, +] diff --git a/src/lib/positionLinks.ts b/src/lib/positionLinks.ts new file mode 100644 index 00000000..786d0d2c --- /dev/null +++ b/src/lib/positionLinks.ts @@ -0,0 +1,135 @@ +import { Chess } from 'chess.ts' + +import { MAIA_MODELS } from 'src/constants/common' + +export interface PositionLinkOptions { + fen: string + challengeId?: string + name?: string + description?: string + pgn?: string + playerColor?: 'white' | 'black' + maiaVersion?: string + targetMoveNumber?: number | null + returnTo?: string + forcedPlayerColor?: 'white' | 'black' + modalTitle?: string + modalSubtitle?: string +} + +export const DEFAULT_POSITION_MAIA_MODEL = 'maia_kdd_1900' +const DEFAULT_POSITION_NAME = 'Tournament Position' + +export const normalizeFen = (fen: string): string => { + const trimmed = fen.trim() + if (!trimmed) return trimmed + + const parts = trimmed.split(/\s+/) + if (parts.length >= 6) { + return parts.slice(0, 6).join(' ') + } + + const defaults: Record = { + 1: 'w', + 2: '-', + 3: '-', + 4: '0', + 5: '1', + } + + const normalized = [...parts] + + for (let index = parts.length; index < 6; index += 1) { + normalized[index] = defaults[index] ?? '0' + } + + if (!normalized[1]) normalized[1] = 'w' + if (!normalized[2]) normalized[2] = '-' + if (!normalized[3]) normalized[3] = '-' + + return normalized.slice(0, 6).join(' ') +} + +export const isValidFen = (fen: string): boolean => { + const normalized = normalizeFen(fen) + if (!normalized) return false + + try { + return new Chess().load(normalized) + } catch { + return false + } +} + +export const inferPlayerColorFromFen = (fen: string): 'white' | 'black' => { + const normalized = normalizeFen(fen) + return normalized.split(' ')[1] === 'b' ? 'black' : 'white' +} + +export const getValidMaiaModel = (model?: string): string => { + if (model && MAIA_MODELS.includes(model)) { + return model + } + + return DEFAULT_POSITION_MAIA_MODEL +} + +const getResolvedPositionName = (name?: string): string => + name?.trim() || DEFAULT_POSITION_NAME + +export const buildAnalysisPositionLink = ( + options: PositionLinkOptions, +): string => { + const params = new URLSearchParams() + const name = getResolvedPositionName(options.name) + const normalizedFen = normalizeFen(options.fen) + const trimmedPgn = options.pgn?.trim() + + params.set('name', name) + if (trimmedPgn) { + params.set('pgn', trimmedPgn) + } else { + params.set('fen', normalizedFen) + } + + return `/analysis/custom?${params.toString()}` +} + +export const buildPositionDrillLink = ( + options: PositionLinkOptions, +): string => { + const params = new URLSearchParams() + const name = getResolvedPositionName(options.name) + const normalizedFen = normalizeFen(options.fen) + params.set('customFen', normalizedFen) + params.set('customName', name) + params.set('tab', 'custom') + + return `/drills?${params.toString()}` +} + +export const buildPositionPlayLink = (options: PositionLinkOptions): string => { + const params = new URLSearchParams() + const normalizedFen = normalizeFen(options.fen) + const forcedPlayerColor = + options.forcedPlayerColor ?? inferPlayerColorFromFen(normalizedFen) + + params.set('fen', normalizedFen) + params.set('maiaVersion', 'maia_kdd_1500') + params.set('timeControl', '10+5') + if (options.returnTo) { + params.set('returnTo', options.returnTo) + } + if (options.challengeId) { + params.set('challengeId', options.challengeId) + } + params.set('forcedColor', forcedPlayerColor) + if (options.modalTitle) { + params.set('modalTitle', options.modalTitle) + } + if (options.modalSubtitle) { + params.set('modalSubtitle', options.modalSubtitle) + } + + return `/play?${params.toString()}` +} diff --git a/src/pages/analysis/custom.tsx b/src/pages/analysis/custom.tsx new file mode 100644 index 00000000..6b29213f --- /dev/null +++ b/src/pages/analysis/custom.tsx @@ -0,0 +1,107 @@ +import Head from 'next/head' +import { NextPage } from 'next' +import { useRouter } from 'next/router' +import { useEffect, useMemo, useState } from 'react' + +import { storeCustomGame } from 'src/api' +import { Loading } from 'src/components' +import { AuthenticatedWrapper } from 'src/components/Common/AuthenticatedWrapper' +import { isValidFen, normalizeFen } from 'src/lib/positionLinks' + +const CustomAnalysisLinkPage: NextPage = () => { + const router = useRouter() + const [error, setError] = useState(null) + + const queryPayload = useMemo(() => { + const fen = + typeof router.query.fen === 'string' ? normalizeFen(router.query.fen) : '' + const pgn = typeof router.query.pgn === 'string' ? router.query.pgn : '' + const name = + typeof router.query.name === 'string' ? router.query.name : undefined + + return { fen, pgn, name } + }, [router.query.fen, router.query.name, router.query.pgn]) + + useEffect(() => { + if (!router.isReady) return + + let cancelled = false + + const run = async () => { + const hasPgn = queryPayload.pgn.trim().length > 0 + const hasFen = queryPayload.fen.length > 0 + + if (!hasPgn && !hasFen) { + setError('Add a PGN or FEN to open a linked analysis position.') + return + } + + if (!hasPgn && !isValidFen(queryPayload.fen)) { + setError('This linked position contains an invalid FEN.') + return + } + + try { + const { game_id } = await storeCustomGame({ + name: queryPayload.name, + pgn: hasPgn ? queryPayload.pgn : undefined, + fen: hasPgn ? undefined : queryPayload.fen, + }) + + if (cancelled) return + + await router.replace(`/analysis/${game_id}/custom`) + } catch (caughtError) { + if (cancelled) return + + const message = + caughtError instanceof Error + ? caughtError.message + : 'Failed to open the linked analysis position.' + setError(message) + } + } + + run() + + return () => { + cancelled = true + } + }, [queryPayload, router, router.isReady]) + + return ( + <> + + Opening Linked Analysis – Maia Chess + + + +
+
+

+ {error + ? 'Linked analysis could not load' + : 'Opening linked analysis'} +

+

+ {error + ? error + : 'Creating a custom analysis view for this position.'} +

+
+
+
+ + ) +} + +export default function AuthenticatedCustomAnalysisLinkPage() { + return ( + + + + ) +} diff --git a/src/pages/candidates.tsx b/src/pages/candidates.tsx new file mode 100644 index 00000000..ad822f11 --- /dev/null +++ b/src/pages/candidates.tsx @@ -0,0 +1,256 @@ +import Head from 'next/head' +import dynamic from 'next/dynamic' +import Link from 'next/link' +import { useRouter } from 'next/router' +import { useEffect, useMemo, useState } from 'react' + +import { + CANDIDATES_FEATURED_POSITIONS, + CANDIDATES_WARMUP_POSITIONS, + CandidatePosition, +} from 'src/constants/candidates' +import { + buildPositionPlayLink, + inferPlayerColorFromFen, +} from 'src/lib/positionLinks' +import { GameTree } from 'src/types' + +const CANDIDATES_COMPLETED_STORAGE_KEY = 'maia-candidates-completed' + +const readCompletedChallenges = (): string[] => { + if (typeof window === 'undefined') return [] + + try { + const stored = window.localStorage.getItem(CANDIDATES_COMPLETED_STORAGE_KEY) + const parsed = stored ? (JSON.parse(stored) as unknown) : [] + if (!Array.isArray(parsed)) return [] + + return parsed.filter((value): value is string => typeof value === 'string') + } catch (error) { + console.warn('Failed to read Candidates completion state:', error) + return [] + } +} + +const GameBoard = dynamic( + () => import('src/components/Board/GameBoard').then((mod) => mod.GameBoard), + { + ssr: false, + loading: () => ( +
+ ), + }, +) + +const accentClasses: Record = { + amber: + 'border-[#463d42] bg-[radial-gradient(circle_at_top,rgba(251,191,36,0.16),transparent_60%),rgba(255,255,255,0.02)]', + blue: 'border-[#463d42] bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.16),transparent_60%),rgba(255,255,255,0.02)]', + red: 'border-[#463d42] bg-[radial-gradient(circle_at_top,rgba(251,113,133,0.16),transparent_60%),rgba(255,255,255,0.02)]', +} + +const completedAccentClass = + 'border-emerald-300/35 bg-[radial-gradient(circle_at_top,rgba(52,211,153,0.18),transparent_62%),rgba(255,255,255,0.03)]' + +const PositionBoard: React.FC<{ + position: CandidatePosition + completed?: boolean +}> = ({ position, completed = false }) => { + const tree = useMemo(() => new GameTree(position.fen), [position.fen]) + const orientation = + position.playerColor ?? inferPlayerColorFromFen(position.fen) + const [showBoard, setShowBoard] = useState(false) + + useEffect(() => { + let frameOne = 0 + let frameTwo = 0 + let resizeTimeout: number | undefined + + frameOne = window.requestAnimationFrame(() => { + frameTwo = window.requestAnimationFrame(() => { + setShowBoard(true) + resizeTimeout = window.setTimeout(() => { + window.dispatchEvent(new Event('resize')) + }, 120) + }) + }) + + return () => { + window.cancelAnimationFrame(frameOne) + window.cancelAnimationFrame(frameTwo) + if (resizeTimeout) { + window.clearTimeout(resizeTimeout) + } + } + }, []) + + return ( +
+ {showBoard ? ( + + ) : ( +
+ )} +
+ ) +} + +const PositionPill: React.FC<{ + position: CandidatePosition + completed?: boolean +}> = ({ position, completed = false }) => { + const playHref = buildPositionPlayLink({ + ...position, + challengeId: position.id, + returnTo: '/candidates', + modalTitle: 'Maia Candidates Challenge', + modalSubtitle: position.title, + }) + + return ( +
+
+
+

+ {position.title} +

+

+ {position.subtitle} +

+
+ + {completed ? 'verified' : 'radio_button_unchecked'} + + + {completed ? 'Completed:' : 'Incomplete'} + + {completed ? Nicely done! : null} +
+
+ + + + + +
+ + + swords + + Challenge Maia + +
+
+
+ ) +} + +export default function CandidatesPage() { + const router = useRouter() + const [completedChallengeIds, setCompletedChallengeIds] = useState( + [], + ) + const positions = useMemo( + () => [...CANDIDATES_FEATURED_POSITIONS, ...CANDIDATES_WARMUP_POSITIONS], + [], + ) + const completedChallengeId = + typeof router.query.completedChallenge === 'string' + ? router.query.completedChallenge + : undefined + + useEffect(() => { + setCompletedChallengeIds(readCompletedChallenges()) + }, []) + + useEffect(() => { + if ( + !router.isReady || + !completedChallengeId || + typeof window === 'undefined' + ) { + return + } + + const current = readCompletedChallenges() + const next = current.includes(completedChallengeId) + ? current + : [...current, completedChallengeId] + + setCompletedChallengeIds(next) + + try { + window.localStorage.setItem( + CANDIDATES_COMPLETED_STORAGE_KEY, + JSON.stringify(next), + ) + } catch (error) { + console.warn('Failed to save Candidates completion state:', error) + } + + router.replace('/candidates', undefined, { shallow: true, scroll: false }) + }, [completedChallengeId, router]) + + return ( + <> + + FIDE Candidates Tournament 2026 – Maia Chess + + + +
+
+
+

+ FIDE Candidates Tournament 2026 +

+

+ Round 1 +

+
+ {positions.map((position) => ( + + ))} +
+
+ + ) +} diff --git a/src/pages/openings/index.tsx b/src/pages/openings/index.tsx index 8f8d3163..9fa2135a 100644 --- a/src/pages/openings/index.tsx +++ b/src/pages/openings/index.tsx @@ -59,6 +59,7 @@ import endgamesRaw from 'src/constants/endgames.json' import { buildEndgameDataset, createEndgameOpenings } from 'src/lib/endgames' import { MAIA_MODELS } from 'src/constants/common' import { cpToWinrate } from 'src/lib/analysis' +import { isValidFen, normalizeFen } from 'src/lib/positionLinks' import { useOpeningDrillController, useAnalysisController } from 'src/hooks' import { @@ -86,6 +87,26 @@ const OpeningsPage: NextPage = () => { () => createEndgameOpenings(endgameDataset), [endgameDataset], ) + const linkedCustomDraft = useMemo(() => { + if (!router.isReady) return null + + const customFen = + typeof router.query.customFen === 'string' + ? normalizeFen(router.query.customFen) + : '' + + if (!customFen || !isValidFen(customFen)) { + return null + } + + return { + input: customFen, + name: + typeof router.query.customName === 'string' + ? router.query.customName + : undefined, + } + }, [router.isReady, router.query.customFen, router.query.customName]) const handleCloseModal = () => { if (drillConfiguration) { @@ -1144,6 +1165,7 @@ const OpeningsPage: NextPage = () => { endgames={endgameOpenings} endgameDataset={endgameDataset} initialSelections={drillConfiguration?.selections || []} + initialCustomDraft={linkedCustomDraft} onComplete={handleCompleteSelection} onClose={handleCloseModal} /> diff --git a/src/pages/play/index.tsx b/src/pages/play/index.tsx index d8b1ced2..d1ce89e0 100644 --- a/src/pages/play/index.tsx +++ b/src/pages/play/index.tsx @@ -10,7 +10,16 @@ const PlayPage: NextPage = () => { const { setPlaySetupModalProps } = useContext(ModalContext) const [launchedModal, setLaunchedModal] = useState(false) - const { fen } = router.query + const { + fen, + returnTo, + challengeId, + forcedColor, + modalTitle, + modalSubtitle, + maiaVersion, + timeControl, + } = router.query useEffect(() => { if (!router.isReady) { @@ -21,10 +30,33 @@ const PlayPage: NextPage = () => { setPlaySetupModalProps({ playType: 'againstMaia', startFen: typeof fen == 'string' ? fen : undefined, + maiaVersion: typeof maiaVersion == 'string' ? maiaVersion : undefined, + timeControl: typeof timeControl == 'string' ? timeControl : undefined, + returnTo: typeof returnTo == 'string' ? returnTo : undefined, + challengeId: typeof challengeId == 'string' ? challengeId : undefined, + forcedPlayerColor: + forcedColor === 'white' || forcedColor === 'black' + ? forcedColor + : undefined, + modalTitle: typeof modalTitle == 'string' ? modalTitle : undefined, + modalSubtitle: + typeof modalSubtitle == 'string' ? modalSubtitle : undefined, }) setLaunchedModal(true) } - }, [router.isReady, fen, launchedModal, setPlaySetupModalProps]) + }, [ + router.isReady, + fen, + challengeId, + forcedColor, + launchedModal, + maiaVersion, + modalSubtitle, + modalTitle, + returnTo, + setPlaySetupModalProps, + timeControl, + ]) useEffect(() => { const handleStart = (url: string) => { diff --git a/src/pages/play/maia.tsx b/src/pages/play/maia.tsx index 07ca4f44..841b215d 100644 --- a/src/pages/play/maia.tsx +++ b/src/pages/play/maia.tsx @@ -15,6 +15,9 @@ interface Props { id: string playGameConfig: PlayGameConfig playAgain: () => void + returnToPath?: string + returnToLabel?: string + challengeId?: string simulateMaiaTime: boolean setSimulateMaiaTime: (value: boolean) => void } @@ -23,14 +26,46 @@ const PlayMaia: React.FC = ({ id, playGameConfig, playAgain, + returnToPath, + returnToLabel, + challengeId, simulateMaiaTime, setSimulateMaiaTime, }: Props) => { + const router = useRouter() const controller = useVsMaiaPlayController( id, playGameConfig, simulateMaiaTime, ) + const returnTo = useMemo(() => { + if (!returnToPath) return undefined + + return () => { + const completedChallenge = + returnToPath.startsWith('/candidates') && + challengeId && + controller.game.termination?.winner === playGameConfig.player + ? challengeId + : undefined + + if (returnToPath.startsWith('/candidates')) { + router.push({ + pathname: '/candidates', + query: completedChallenge ? { completedChallenge } : undefined, + }) + return + } + + router.push(returnToPath) + } + }, [ + challengeId, + controller.game.termination?.winner, + playGameConfig.player, + returnToPath, + router, + ]) useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { @@ -65,6 +100,8 @@ const PlayMaia: React.FC = ({ controller.setResigned(true) }} playAgain={playAgain} + returnTo={returnTo} + returnToLabel={returnToLabel} simulateMaiaTime={simulateMaiaTime} setSimulateMaiaTime={setSimulateMaiaTime} /> @@ -98,6 +135,11 @@ const PlayMaiaPage: NextPage = () => { sampleMoves, simulateMaiaTime: simulateMaiaTimeQuery, startFen, + returnTo, + challengeId, + forcedColor, + modalTitle, + modalSubtitle, } = router.query const [simulateMaiaTime, setSimulateMaiaTime] = useState( @@ -105,6 +147,11 @@ const PlayMaiaPage: NextPage = () => { ? true : false, ) + const returnToLabel = useMemo(() => { + if (typeof returnTo !== 'string') return undefined + if (returnTo.startsWith('/candidates')) return 'Back to Candidates' + return 'Back to Previous Page' + }, [returnTo]) const playGameConfig: PlayGameConfig = useMemo( () => ({ @@ -162,6 +209,11 @@ const PlayMaiaPage: NextPage = () => { query: { id: newGameId, ...playGameConfig, + returnTo, + challengeId, + forcedColor, + modalTitle, + modalSubtitle, }, }, { @@ -171,6 +223,11 @@ const PlayMaiaPage: NextPage = () => { // so that if the page is manually refreshed // the old game ID is not persisted ...playGameConfig, + returnTo, + challengeId, + forcedColor, + modalTitle, + modalSubtitle, }, }, ) @@ -184,7 +241,16 @@ const PlayMaiaPage: NextPage = () => { canceled = true } } - }, [id, playGameConfig, router]) + }, [ + challengeId, + forcedColor, + id, + modalSubtitle, + modalTitle, + playGameConfig, + returnTo, + router, + ]) return ( <> @@ -200,7 +266,27 @@ const PlayMaiaPage: NextPage = () => { setPlaySetupModalProps({ ...playGameConfig })} + playAgain={() => + setPlaySetupModalProps({ + ...playGameConfig, + returnTo: typeof returnTo === 'string' ? returnTo : undefined, + challengeId: + typeof challengeId === 'string' ? challengeId : undefined, + forcedPlayerColor: + forcedColor === 'white' || forcedColor === 'black' + ? forcedColor + : undefined, + modalTitle: + typeof modalTitle === 'string' ? modalTitle : undefined, + modalSubtitle: + typeof modalSubtitle === 'string' ? modalSubtitle : undefined, + }) + } + returnToPath={typeof returnTo === 'string' ? returnTo : undefined} + returnToLabel={returnToLabel} + challengeId={ + typeof challengeId === 'string' ? challengeId : undefined + } simulateMaiaTime={simulateMaiaTime} setSimulateMaiaTime={setSimulateMaiaTime} /> diff --git a/src/pages/puzzles.tsx b/src/pages/puzzles.tsx index aeb85172..7959c12c 100644 --- a/src/pages/puzzles.tsx +++ b/src/pages/puzzles.tsx @@ -16,7 +16,7 @@ import { useRouter } from 'next/router' import type { Key } from 'chessground/types' import type { DrawShape } from 'chessground/draw' import { Chess, PieceSymbol } from 'chess.ts' -import { AnimatePresence, motion } from 'framer-motion' +import { AnimatePresence, motion, useSpring, useTransform } from 'framer-motion' import { fetchPuzzle, logPuzzleGuesses, @@ -38,9 +38,13 @@ import { GameBoard, PromotionOverlay, DownloadModelModal, - Highlight, - BlunderMeter, AnalysisSidebar, + AnalysisArrowLegend, + AnalysisCompactBlunderMeter, + AnalysisMaiaWinrateBar, + AnalysisStockfishEvalBar, + SimplifiedAnalysisOverview, + MovesByRating, } from 'src/components' import { useTrainingController } from 'src/hooks/useTrainingController' import { useAnalysisController } from 'src/hooks/useAnalysisController' @@ -54,8 +58,17 @@ import { getAvailableMovesArray, requiresPromotion, } from 'src/lib/puzzle' +import { cpToWinrate } from 'src/lib/analysis' import { tourConfigs } from 'src/constants/tours' +const EVAL_BAR_RANGE = 4 +const DEFAULT_STOCKFISH_EVAL_BAR = { + hasEval: false, + pawns: 0, + displayPawns: 0, + label: '--', +} + const statsLoader = async () => { const stats = await fetchTrainingPlayerStats() return { @@ -82,6 +95,7 @@ const TrainPage: NextPage = () => { const [lastAttemptedMove, setLastAttemptedMove] = useState( null, ) + const [solutionMoveSan, setSolutionMoveSan] = useState(null) useEffect(() => { if (!initialTourCheck && tourState.ready) { @@ -107,6 +121,7 @@ const TrainPage: NextPage = () => { setStatus('default') setUserGuesses([]) setLastAttemptedMove(null) + setSolutionMoveSan(null) setCurrentIndex(trainingGames.length) setTrainingGames(trainingGames.concat([game])) setPreviousGameResults(previousGameResults.concat([{ ...game }])) @@ -144,6 +159,16 @@ const TrainPage: NextPage = () => { newGuesses, status === 'forfeit', ) + const solutionMoveUci = response.correct_moves?.[0] + const solutionSan = + solutionMoveUci && trainingGames[puzzleIdx] + ? (trainingGames[puzzleIdx].availableMoves?.[solutionMoveUci]?.san ?? + null) + : null + + if (solutionSan) { + setSolutionMoveSan(solutionSan) + } if (status === 'forfeit') { setPreviousGameResults((prev) => { @@ -243,24 +268,22 @@ const TrainPage: NextPage = () => { logGuess={logGuess} lastAttemptedMove={lastAttemptedMove} setLastAttemptedMove={setLastAttemptedMove} + solutionMoveSan={solutionMoveSan} gamesController={ - <> -
-
- -
-
- -
+
+
+
- +
+ +
+
} /> ) @@ -289,6 +312,7 @@ interface Props { ) => void lastAttemptedMove: string | null setLastAttemptedMove: Dispatch> + solutionMoveSan: string | null } const Train: React.FC = ({ @@ -301,6 +325,7 @@ const Train: React.FC = ({ logGuess, lastAttemptedMove, setLastAttemptedMove, + solutionMoveSan, }: Props) => { const controller = useTrainingController(trainingGame) @@ -314,7 +339,7 @@ const Train: React.FC = ({ false, // Disable auto-saving on puzzles page ) - const { width } = useContext(WindowSizeContext) + const { width, height } = useContext(WindowSizeContext) const isMobile = useMemo( () => width > 0 && width <= TABLET_BREAKPOINT_PX, [width], @@ -327,6 +352,14 @@ const Train: React.FC = ({ const [userAnalysisEnabled, setUserAnalysisEnabled] = useState< boolean | null >(null) // User's choice, null means not set + const desktopBoardHeaderStripRef = useRef(null) + const desktopBlunderMeterSectionRef = useRef(null) + const desktopBoardControllerSectionRef = useRef(null) + const [desktopMeasuredHeights, setDesktopMeasuredHeights] = useState({ + headerPx: 28, + blunderMeterPx: 126, + boardControllerPx: 44, + }) const showAnalysis = status === 'correct' || status === 'forfeit' || status === 'archived' @@ -365,6 +398,308 @@ const Train: React.FC = ({ [], ) + const compactBlunderMeterData = useMemo( + () => + analysisEnabled && showAnalysis + ? analysisController.blunderMeter + : emptyBlunderMeterData, + [ + analysisEnabled, + showAnalysis, + analysisController.blunderMeter, + emptyBlunderMeterData, + ], + ) + + const rawStockfishEvalBar = useMemo(() => { + const stockfish = analysisController.moveEvaluation?.stockfish + const sideToMove = analysisController.currentNode?.turn || 'w' + + if (!stockfish) { + return { + ...DEFAULT_STOCKFISH_EVAL_BAR, + depth: 0, + } + } + + const mateIn = stockfish.mate_vec?.[stockfish.model_move] + if (mateIn !== undefined) { + const matingColor = + mateIn > 0 ? sideToMove : sideToMove === 'w' ? 'b' : 'w' + const whitePerspectiveSign = matingColor === 'w' ? 1 : -1 + return { + hasEval: true, + pawns: whitePerspectiveSign * EVAL_BAR_RANGE, + displayPawns: whitePerspectiveSign * EVAL_BAR_RANGE, + label: `M${Math.abs(mateIn)}`, + depth: stockfish.depth ?? 0, + } + } + + const cp = + stockfish.model_optimal_cp ?? Object.values(stockfish.cp_vec)[0] ?? 0 + const rawPawns = cp / 100 + const clampedPawns = Math.max( + -EVAL_BAR_RANGE, + Math.min(EVAL_BAR_RANGE, rawPawns), + ) + + return { + hasEval: true, + pawns: clampedPawns, + displayPawns: rawPawns, + label: `${rawPawns > 0 ? '+' : ''}${rawPawns.toFixed(2)}`, + depth: stockfish.depth ?? 0, + } + }, [ + analysisController.currentNode?.turn, + analysisController.moveEvaluation?.stockfish, + ]) + + const displayedStockfishEvalText = useMemo(() => { + if (!analysisEnabled || !showAnalysis || !rawStockfishEvalBar.hasEval) { + return '--' + } + + if (rawStockfishEvalBar.label.startsWith('M')) { + return rawStockfishEvalBar.label + } + + const roundedPawns = Math.round(rawStockfishEvalBar.displayPawns * 10) / 10 + const safePawns = Math.abs(roundedPawns) < 0.05 ? 0 : roundedPawns + return `${safePawns > 0 ? '+' : ''}${safePawns.toFixed(1)}` + }, [ + analysisEnabled, + showAnalysis, + rawStockfishEvalBar.displayPawns, + rawStockfishEvalBar.hasEval, + rawStockfishEvalBar.label, + ]) + + const evalPositionPercent = useMemo(() => { + const normalized = + (rawStockfishEvalBar.pawns + EVAL_BAR_RANGE) / (EVAL_BAR_RANGE * 2) + return Math.max(0, Math.min(1, normalized)) * 100 + }, [rawStockfishEvalBar.pawns]) + + const smoothedEvalPosition = useSpring(50, { + stiffness: 520, + damping: 42, + mass: 0.25, + }) + const smoothedEvalVerticalPositionLabel = useTransform( + smoothedEvalPosition, + (value) => `${100 - value}%`, + ) + + useEffect(() => { + smoothedEvalPosition.set( + analysisEnabled && showAnalysis && rawStockfishEvalBar.hasEval + ? evalPositionPercent + : 50, + ) + }, [ + analysisEnabled, + showAnalysis, + rawStockfishEvalBar.hasEval, + evalPositionPercent, + smoothedEvalPosition, + ]) + + const currentTurnForBars: 'w' | 'b' = + analysisController.currentNode?.turn || 'w' + + const isCurrentPositionCheckmateForBars = useMemo(() => { + if (!analysisController.currentNode) return false + + try { + const chess = new Chess(analysisController.currentNode.fen) + return chess.inCheckmate() + } catch { + return false + } + }, [analysisController.currentNode]) + + const isInFirst10PlyForBars = useMemo(() => { + if (!analysisController.currentNode) return false + + const moveNumber = analysisController.currentNode.moveNumber + const turn = analysisController.currentNode.turn + const plyFromStart = (moveNumber - 1) * 2 + (turn === 'b' ? 1 : 0) + return plyFromStart < 10 + }, [analysisController.currentNode]) + + const rawMaiaWhiteWinBar = useMemo(() => { + const stockfishEval = analysisController.moveEvaluation?.stockfish + + if (isCurrentPositionCheckmateForBars) { + const percent = currentTurnForBars === 'w' ? 0 : 100 + return { hasValue: true, percent, label: `${percent.toFixed(1)}%` } + } + + if (stockfishEval?.is_checkmate) { + const percent = currentTurnForBars === 'w' ? 0 : 100 + return { hasValue: true, percent, label: `${percent.toFixed(1)}%` } + } + + if ( + stockfishEval?.model_move && + stockfishEval.mate_vec && + stockfishEval.mate_vec[stockfishEval.model_move] !== undefined + ) { + const mateValue = stockfishEval.mate_vec[stockfishEval.model_move] + const deliveringColor = + mateValue > 0 + ? currentTurnForBars + : currentTurnForBars === 'w' + ? 'b' + : 'w' + const percent = deliveringColor === 'w' ? 100 : 0 + return { hasValue: true, percent, label: `${percent.toFixed(1)}%` } + } + + if ( + isInFirst10PlyForBars && + stockfishEval?.model_optimal_cp !== undefined + ) { + const percent = Math.max( + 0, + Math.min(100, cpToWinrate(stockfishEval.model_optimal_cp) * 100), + ) + return { + hasValue: true, + percent, + label: `${(Math.round(percent * 10) / 10).toFixed(1)}%`, + } + } + + if (analysisController.moveEvaluation?.maia) { + const percent = Math.max( + 0, + Math.min(100, analysisController.moveEvaluation.maia.value * 100), + ) + return { + hasValue: true, + percent, + label: `${(Math.round(percent * 10) / 10).toFixed(1)}%`, + } + } + + return { hasValue: false, percent: 50, label: '--' } + }, [ + analysisController.moveEvaluation?.maia, + analysisController.moveEvaluation?.stockfish, + currentTurnForBars, + isCurrentPositionCheckmateForBars, + isInFirst10PlyForBars, + ]) + + const maiaWhiteWinPositionPercent = useMemo( + () => Math.max(0, Math.min(100, rawMaiaWhiteWinBar.percent)), + [rawMaiaWhiteWinBar.percent], + ) + const renderedMaiaWhiteWinBar = useMemo( + () => + analysisEnabled && showAnalysis + ? rawMaiaWhiteWinBar + : { hasValue: false, percent: 50, label: '--' }, + [analysisEnabled, showAnalysis, rawMaiaWhiteWinBar], + ) + + const smoothedMaiaWhiteWinPosition = useSpring(50, { + stiffness: 520, + damping: 42, + mass: 0.25, + }) + const smoothedMaiaWhiteWinVerticalPositionLabel = useTransform( + smoothedMaiaWhiteWinPosition, + (value) => `${100 - value}%`, + ) + + useEffect(() => { + smoothedMaiaWhiteWinPosition.set( + analysisEnabled && showAnalysis ? maiaWhiteWinPositionPercent : 50, + ) + }, [ + analysisEnabled, + showAnalysis, + maiaWhiteWinPositionPercent, + smoothedMaiaWhiteWinPosition, + ]) + + useEffect(() => { + if (isMobile) return + + const headerEl = desktopBoardHeaderStripRef.current + const blunderEl = desktopBlunderMeterSectionRef.current + const controllerEl = desktopBoardControllerSectionRef.current + + if (!headerEl && !blunderEl && !controllerEl) return + + const next = { + headerPx: + headerEl?.getBoundingClientRect().height ?? + desktopMeasuredHeights.headerPx, + blunderMeterPx: + blunderEl?.getBoundingClientRect().height ?? + desktopMeasuredHeights.blunderMeterPx, + boardControllerPx: + controllerEl?.getBoundingClientRect().height ?? + desktopMeasuredHeights.boardControllerPx, + } + + setDesktopMeasuredHeights((prev) => { + if ( + Math.abs(prev.headerPx - next.headerPx) < 0.5 && + Math.abs(prev.blunderMeterPx - next.blunderMeterPx) < 0.5 && + Math.abs(prev.boardControllerPx - next.boardControllerPx) < 0.5 + ) { + return prev + } + + return next + }) + }, [desktopMeasuredHeights, isMobile, showAnalysis, status, width]) + + const desktopColumnTargetHeightCss = '85vh' + const desktopBoardBaselineSizeCss = 'min(42vw, 72vh)' + const desktopBoardWidthCapVw = useMemo(() => { + if (width >= 1536) return 48 + if (width >= 1280) return 46 + return 42 + }, [width]) + const desktopBoardHeightCapPx = useMemo(() => { + if (height <= 0) return null + + const targetColumnHeightPx = height * 0.85 + const gapAllowancePx = showAnalysis ? 24 : 12 + const measuredNonBoardHeightPx = + desktopMeasuredHeights.headerPx + + (showAnalysis ? desktopMeasuredHeights.blunderMeterPx : 0) + + desktopMeasuredHeights.boardControllerPx + + gapAllowancePx + + return Math.max( + 340, + Math.floor(targetColumnHeightPx - measuredNonBoardHeightPx), + ) + }, [desktopMeasuredHeights, height, showAnalysis]) + const desktopBoardSizeCss = useMemo(() => { + const heightCapCss = + desktopBoardHeightCapPx !== null ? `${desktopBoardHeightCapPx}px` : '72vh' + const expandedTargetCss = `min(${desktopBoardWidthCapVw}vw, ${heightCapCss})` + + return `max(${desktopBoardBaselineSizeCss}, ${expandedTargetCss})` + }, [ + desktopBoardBaselineSizeCss, + desktopBoardHeightCapPx, + desktopBoardWidthCapVw, + ]) + const desktopBoardMinSizeCss = useMemo( + () => `calc(max(24rem, ${desktopBoardSizeCss}))`, + [desktopBoardSizeCss], + ) + const currentPlayer = useMemo(() => { const currentNode = analysisEnabled && showAnalysis @@ -379,9 +714,9 @@ const Train: React.FC = ({ ]) useEffect(() => { if (analysisEnabled && showAnalysis && !analysisSyncedRef.current) { - // Set the analysis controller to the current training controller's node - // Only sync once when analysis mode is first enabled - analysisController.setCurrentNode(controller.currentNode) + // Start post-puzzle analysis from the original puzzle position rather + // than the solution move that may have just been played. + analysisController.setCurrentNode(controller.puzzleStartingNode) analysisSyncedRef.current = true } else if (!showAnalysis || !analysisEnabled) { // Reset sync flag when exiting analysis mode @@ -391,7 +726,7 @@ const Train: React.FC = ({ analysisEnabled, showAnalysis, analysisController, - controller.currentNode, + controller.puzzleStartingNode, ]) const onSelectSquare = useCallback( @@ -706,14 +1041,13 @@ const Train: React.FC = ({ exit="exit" style={{ willChange: 'transform, opacity' }} > -
+
-
- {/* Header */} +
@@ -732,133 +1066,242 @@ const Train: React.FC = ({
+
- {/* Puzzle log */} -
-
- {gamesController} -
- - {/* Stats */} -
-
- -
+
+ {gamesController}
+ + + + -
- +
+
+ + White Win % + +
+
+ +
+
+ + SF Eval + +
+
+
+
+ +
+
+ + {promotionFromTo ? ( + + ) : null} +
+
+ +
+
+
+ +
+
+ ) : ( +
+ + {promotionFromTo ? ( + + ) : null} +
+ )} +
+ - {promotionFromTo ? ( - - ) : null} -
- -
-
@@ -869,6 +1312,12 @@ const Train: React.FC = ({ setHoverArrow={setHoverArrow} analysisEnabled={analysisEnabled} handleToggleAnalysis={handleToggleAnalysis} + hideDetailedBlunderMeter={true} + containerStyle={{ + width: 'clamp(23rem, 27vw, 26rem)', + minWidth: '23rem', + flexBasis: 'clamp(23rem, 27vw, 26rem)', + }} itemVariants={itemVariants} />
@@ -909,55 +1358,142 @@ const Train: React.FC = ({
-
- - {promotionFromTo ? ( - +
+
+ + Maia % + +
+
+ +
+
+ + SF Eval + +
+
+
+
+ +
+
+ + {promotionFromTo ? ( + + ) : null} +
+
+ +
+
+ - ) : null} -
+
+ ) : ( +
+ + {promotionFromTo ? ( + + ) : null} +
+ )}
= ({ getNewGame={getNewGame} lastAttemptedMove={lastAttemptedMove} setLastAttemptedMove={setLastAttemptedMove} + solutionMoveSan={solutionMoveSan} />
@@ -1051,59 +1588,81 @@ const Train: React.FC = ({
)} -
- void 0 - } - hover={analysisEnabled && showAnalysis ? hover : mockHover} - makeMove={ - analysisEnabled && showAnalysis ? makeMove : mockMakeMove - } - currentMaiaModel={ - analysisEnabled && showAnalysis - ? analysisController.currentMaiaModel - : 'maia_kdd_1500' - } - recommendations={ - analysisEnabled && showAnalysis - ? analysisController.moveRecommendations - : emptyRecommendations - } - moveEvaluation={ - analysisEnabled && showAnalysis - ? (analysisController.moveEvaluation as { - maia?: MaiaEvaluation - stockfish?: StockfishEvaluation - }) - : { - maia: undefined, - stockfish: undefined, - } - } - colorSanMapping={ - analysisEnabled && showAnalysis - ? analysisController.colorSanMapping - : {} - } - boardDescription={ - analysisEnabled && showAnalysis - ? analysisController.boardDescription - : { - segments: [ - { - type: 'text', - content: - 'Complete the puzzle to unlock analysis, or analysis is disabled.', - }, - ], - } - } +
+ void 0, + hover: analysisEnabled && showAnalysis ? hover : mockHover, + makeMove: + analysisEnabled && showAnalysis ? makeMove : mockMakeMove, + currentMaiaModel: + analysisEnabled && showAnalysis + ? analysisController.currentMaiaModel + : 'maia_kdd_1500', + recommendations: + analysisEnabled && showAnalysis + ? analysisController.moveRecommendations + : emptyRecommendations, + moveEvaluation: + analysisEnabled && showAnalysis + ? (analysisController.moveEvaluation as { + maia?: MaiaEvaluation + stockfish?: StockfishEvaluation + }) + : { + maia: undefined, + stockfish: undefined, + }, + colorSanMapping: + analysisEnabled && showAnalysis + ? analysisController.colorSanMapping + : {}, + boardDescription: + analysisEnabled && showAnalysis + ? analysisController.boardDescription + : { + segments: [ + { + type: 'text', + content: + 'Complete the puzzle to unlock analysis, or analysis is disabled.', + }, + ], + }, + currentNode: analysisController.currentNode ?? undefined, + simplified: true, + hideWhiteWinRateSummary: true, + hideStockfishEvalSummary: true, + }} + blunderMeterProps={{ + hover: analysisEnabled && showAnalysis ? hover : mockHover, + makeMove: + analysisEnabled && showAnalysis ? makeMove : mockMakeMove, + data: + analysisEnabled && showAnalysis + ? analysisController.blunderMeter + : emptyBlunderMeterData, + colorSanMapping: + analysisEnabled && showAnalysis + ? analysisController.colorSanMapping + : {}, + moveEvaluation: + analysisEnabled && showAnalysis + ? analysisController.moveEvaluation + : undefined, + playerToMove: + analysisEnabled && showAnalysis + ? (analysisController.currentNode?.turn ?? 'w') + : 'w', + }} + analysisEnabled={analysisEnabled && showAnalysis} + hideBlunderMeter={true} /> {!analysisEnabled && showAnalysis && ( -
+
lock @@ -1129,31 +1688,22 @@ const Train: React.FC = ({
- {!analysisEnabled && showAnalysis && (