diff --git a/.vscode/settings.json b/.vscode/settings.json
index a62c7237..1f0128a0 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -6,5 +6,7 @@
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[dockerfile]": {
"editor.defaultFormatter": "ms-azuretools.vscode-docker"
- }
+ },
+ "editor.tabCompletion": "on",
+ "github.copilot.nextEditSuggestions.enabled": true
}
diff --git a/__tests__/analysis/makeMove-fen.test.ts b/__tests__/analysis/makeMove-fen.test.ts
deleted file mode 100644
index 5643bb7a..00000000
--- a/__tests__/analysis/makeMove-fen.test.ts
+++ /dev/null
@@ -1,202 +0,0 @@
-import { GameTree, GameNode } from 'src/types/base/tree'
-import { Chess, PieceSymbol } from 'chess.ts'
-
-describe('Analysis Page makeMove Logic for FEN Positions', () => {
- // Simulate the makeMove function logic from the analysis page
- const simulateMakeMove = (
- gameTree: GameTree,
- currentNode: GameNode,
- move: string,
- currentMaiaModel?: string,
- ) => {
- const chess = new Chess(currentNode.fen)
- const moveAttempt = chess.move({
- from: move.slice(0, 2),
- to: move.slice(2, 4),
- promotion: move[4] ? (move[4] as PieceSymbol) : undefined,
- })
-
- if (moveAttempt) {
- const newFen = chess.fen()
- const moveString =
- moveAttempt.from +
- moveAttempt.to +
- (moveAttempt.promotion ? moveAttempt.promotion : '')
- const san = moveAttempt.san
-
- // This is the current logic from the analysis page that we need to fix
- if (currentNode.mainChild?.move === moveString) {
- return { type: 'navigate', node: currentNode.mainChild }
- } else {
- // ISSUE: Always creates variation, never main line for first move
- const newVariation = gameTree.addVariation(
- currentNode,
- newFen,
- moveString,
- san,
- currentMaiaModel,
- )
- return { type: 'variation', node: newVariation }
- }
- }
- return null
- }
-
- // Fixed version of makeMove logic
- const simulateFixedMakeMove = (
- gameTree: GameTree,
- currentNode: GameNode,
- move: string,
- currentMaiaModel?: string,
- ) => {
- const chess = new Chess(currentNode.fen)
- const moveAttempt = chess.move({
- from: move.slice(0, 2),
- to: move.slice(2, 4),
- promotion: move[4] ? (move[4] as PieceSymbol) : undefined,
- })
-
- if (moveAttempt) {
- const newFen = chess.fen()
- const moveString =
- moveAttempt.from +
- moveAttempt.to +
- (moveAttempt.promotion ? moveAttempt.promotion : '')
- const san = moveAttempt.san
-
- if (currentNode.mainChild?.move === moveString) {
- // Existing main line move - navigate to it
- return { type: 'navigate', node: currentNode.mainChild }
- } else if (!currentNode.mainChild) {
- // No main child exists - create main line move (FIX)
- const newMainMove = gameTree.addMainMove(
- currentNode,
- newFen,
- moveString,
- san,
- currentMaiaModel,
- )
- return { type: 'main', node: newMainMove }
- } else {
- // Main child exists but different move - create variation
- const newVariation = gameTree.addVariation(
- currentNode,
- newFen,
- moveString,
- san,
- currentMaiaModel,
- )
- return { type: 'variation', node: newVariation }
- }
- }
- return null
- }
-
- describe('Current behavior (broken)', () => {
- it('incorrectly creates variations for first move from FEN position', () => {
- const customFen =
- 'r1bqkb1r/pppp1ppp/2n2n2/4p3/2B1P3/3P1N2/PPP2PPP/RNBQK2R w KQkq - 4 4'
- const tree = new GameTree(customFen)
- const rootNode = tree.getRoot()
-
- // Simulate making the first move from FEN position
- const result = simulateMakeMove(tree, rootNode, 'f3g5')
-
- // ISSUE: First move incorrectly creates a variation instead of main line
- expect(result?.type).toBe('variation')
- expect(rootNode.mainChild).toBeNull() // No main line created
- expect(rootNode.children.length).toBe(1)
- expect(rootNode.getVariations().length).toBe(1) // Created as variation
-
- // The main line should only contain the root
- const mainLine = tree.getMainLine()
- expect(mainLine.length).toBe(1) // Only root, no main line progression
- })
-
- it('shows the problem when making multiple moves from FEN', () => {
- const customFen =
- 'r1bqkb1r/pppp1ppp/2n2n2/4p3/2B1P3/3P1N2/PPP2PPP/RNBQK2R w KQkq - 4 4'
- const tree = new GameTree(customFen)
- const rootNode = tree.getRoot()
-
- // Make first move
- const result1 = simulateMakeMove(tree, rootNode, 'f3g5')
- expect(result1?.type).toBe('variation')
-
- // Make second move from same position
- const result2 = simulateMakeMove(tree, rootNode, 'f3e5')
- expect(result2?.type).toBe('variation')
-
- // Both moves are variations, no main line established
- expect(rootNode.mainChild).toBeNull()
- expect(rootNode.getVariations().length).toBe(2)
- expect(tree.getMainLine().length).toBe(1) // Still just root
- })
- })
-
- describe('Fixed behavior', () => {
- it('correctly creates main line for first move from FEN position', () => {
- const customFen =
- 'r1bqkb1r/pppp1ppp/2n2n2/4p3/2B1P3/3P1N2/PPP2PPP/RNBQK2R w KQkq - 4 4'
- const tree = new GameTree(customFen)
- const rootNode = tree.getRoot()
-
- // Simulate making the first move from FEN position with fix
- const result = simulateFixedMakeMove(tree, rootNode, 'f3g5')
-
- // FIXED: First move creates main line
- expect(result?.type).toBe('main')
- expect(rootNode.mainChild).toBeTruthy() // Main line created
- expect(rootNode.mainChild?.isMainline).toBe(true)
- expect(rootNode.getVariations().length).toBe(0) // No variations yet
-
- // The main line should now contain root + first move
- const mainLine = tree.getMainLine()
- expect(mainLine.length).toBe(2) // Root + one move
- })
-
- it('correctly handles subsequent moves: main line extension and variations', () => {
- const customFen =
- 'r1bqkb1r/pppp1ppp/2n2n2/4p3/2B1P3/3P1N2/PPP2PPP/RNBQK2R w KQkq - 4 4'
- const tree = new GameTree(customFen)
- const rootNode = tree.getRoot()
-
- // First move - should create main line
- const result1 = simulateFixedMakeMove(tree, rootNode, 'f3g5')
- expect(result1?.type).toBe('main')
- const firstMove = result1?.node as GameNode
-
- // Second move from same position - should create variation
- const result2 = simulateFixedMakeMove(tree, rootNode, 'f3e5')
- expect(result2?.type).toBe('variation')
-
- // Third move extending main line - should be main line
- const result3 = simulateFixedMakeMove(tree, firstMove, 'd7d6')
- expect(result3?.type).toBe('main')
-
- // Verify final structure
- expect(rootNode.mainChild).toBeTruthy()
- expect(rootNode.getVariations().length).toBe(1) // One variation
- expect(tree.getMainLine().length).toBe(3) // Root + two main moves
- })
-
- it('correctly navigates to existing moves', () => {
- const customFen =
- 'r1bqkb1r/pppp1ppp/2n2n2/4p3/2B1P3/3P1N2/PPP2PPP/RNBQK2R w KQkq - 4 4'
- const tree = new GameTree(customFen)
- const rootNode = tree.getRoot()
-
- // Create a move first
- const result1 = simulateFixedMakeMove(tree, rootNode, 'f3g5')
- const existingNode = result1?.node
-
- // Try the same move again - should navigate to existing node
- const result2 = simulateFixedMakeMove(tree, rootNode, 'f3g5')
- expect(result2?.type).toBe('navigate')
- expect(result2?.node).toBe(existingNode)
-
- // Structure should remain unchanged
- expect(rootNode.children.length).toBe(1)
- })
- })
-})
diff --git a/__tests__/analysis/makeMove-variation-fix.test.ts b/__tests__/analysis/makeMove-variation-fix.test.ts
deleted file mode 100644
index dc8cca63..00000000
--- a/__tests__/analysis/makeMove-variation-fix.test.ts
+++ /dev/null
@@ -1,251 +0,0 @@
-import { GameTree, GameNode } from 'src/types/base/tree'
-import { Chess, PieceSymbol } from 'chess.ts'
-
-describe('makeMove Logic - Variation Continuation Test', () => {
- // Test specifically for Kevin's feedback: when we're in a variation and make a move,
- // it should continue the variation, not create a new main line
-
- it('should continue variation when making moves from variation nodes', () => {
- const initialFen =
- 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'
- const gameTree = new GameTree(initialFen)
- const root = gameTree.getRoot()
-
- // Step 1: Create main line move (e2e4)
- const mainMove = gameTree.addMainMove(
- root,
- 'rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2',
- 'e2e4',
- 'e4',
- )
-
- // Step 2: Create a variation from root (d2d4)
- const variation = gameTree.addVariation(
- root,
- 'rnbqkbnr/ppp1pppp/8/3p4/3P4/8/PPP1PPPP/RNBQKBNR w KQkq d6 0 2',
- 'd2d4',
- 'd4',
- )
-
- // Verify setup
- expect(root.mainChild).toBe(mainMove)
- expect(root.mainChild?.isMainline).toBe(true)
- expect(variation.isMainline).toBe(false)
- expect(root.children).toHaveLength(2)
-
- // Step 3: Simulate makeMove logic when currentNode is the variation
- const simulateMakeMove = (currentNode: GameNode, move: string) => {
- const chess = new Chess(currentNode.fen)
- const moveAttempt = chess.move({
- from: move.slice(0, 2),
- to: move.slice(2, 4),
- promotion: move[4] ? (move[4] as PieceSymbol) : undefined,
- })
-
- if (moveAttempt) {
- const newFen = chess.fen()
- const moveString =
- moveAttempt.from + moveAttempt.to + (moveAttempt.promotion || '')
- const san = moveAttempt.san
-
- // This is the FIXED logic from the analysis page
- if (currentNode.mainChild?.move === moveString) {
- return { type: 'navigate', node: currentNode.mainChild }
- } else if (!currentNode.mainChild && currentNode.isMainline) {
- // Only create main line if no main child AND we're on main line
- const newMainMove = gameTree.addMainMove(
- currentNode,
- newFen,
- moveString,
- san,
- )
- return { type: 'main_line', node: newMainMove }
- } else {
- // Either main child exists but different move, OR we're in variation - create variation
- const newVariation = gameTree.addVariation(
- currentNode,
- newFen,
- moveString,
- san,
- )
- return { type: 'variation', node: newVariation }
- }
- }
- return null
- }
-
- // Step 4: Make move from the variation node (should create another variation, not main line)
- const result = simulateMakeMove(variation, 'g1f3') as {
- type: 'variation'
- node: GameNode
- }
-
- // Assertions
- expect(result).not.toBeNull()
- expect(result.type).toBe('variation')
- expect(result.node.isMainline).toBe(false)
- expect(variation.mainChild).toBeNull() // variation should not have gained a main child
- expect(variation.children).toHaveLength(1) // should have one child (the move we just made)
- expect(variation.children[0].isMainline).toBe(false) // that child should be a variation
- expect(variation.children[0].move).toBe('g1f3')
- expect(variation.children[0].san).toBe('Nf3')
- })
-
- it('should create main line when making first move from FEN on main line', () => {
- const customFen =
- 'r1bqkbnr/pppp1ppp/2n5/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq - 2 3'
- const gameTree = new GameTree(customFen)
- const root = gameTree.getRoot()
-
- const simulateMakeMove = (currentNode: GameNode, move: string) => {
- const chess = new Chess(currentNode.fen)
- const moveAttempt = chess.move({
- from: move.slice(0, 2),
- to: move.slice(2, 4),
- promotion: move[4] ? (move[4] as PieceSymbol) : undefined,
- })
-
- if (moveAttempt) {
- const newFen = chess.fen()
- const moveString =
- moveAttempt.from + moveAttempt.to + (moveAttempt.promotion || '')
- const san = moveAttempt.san
-
- if (currentNode.mainChild?.move === moveString) {
- return { type: 'navigate', node: currentNode.mainChild }
- } else if (!currentNode.mainChild && currentNode.isMainline) {
- const newMainMove = gameTree.addMainMove(
- currentNode,
- newFen,
- moveString,
- san,
- )
- return { type: 'main_line', node: newMainMove }
- } else {
- const newVariation = gameTree.addVariation(
- currentNode,
- newFen,
- moveString,
- san,
- )
- return { type: 'variation', node: newVariation }
- }
- }
- return null
- }
-
- // Make first move from FEN position (should be main line since root is on main line)
- const result = simulateMakeMove(root, 'g1f3') as {
- type: 'main_line'
- node: GameNode
- }
-
- expect(result).not.toBeNull()
- expect(result.type).toBe('main_line')
- expect(result.node.isMainline).toBe(true)
- expect(root.mainChild).toBe(result.node)
- })
-
- it('should handle complex variation tree correctly', () => {
- const initialFen =
- 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'
- const gameTree = new GameTree(initialFen)
- const root = gameTree.getRoot()
-
- // Create main line: e4
- const e4 = gameTree.addMainMove(
- root,
- 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1',
- 'e2e4',
- 'e4',
- )
-
- // Create variation from root: d4
- const d4 = gameTree.addVariation(
- root,
- 'rnbqkbnr/pppppppp/8/8/3P4/8/PPP1PPPP/RNBQKBNR b KQkq d3 0 1',
- 'd2d4',
- 'd4',
- )
-
- // Create variation from root: Nf3
- const nf3 = gameTree.addVariation(
- root,
- 'rnbqkbnr/pppppppp/8/8/8/5N2/PPPPPPPP/RNBQKB1R b KQkq - 1 1',
- 'g1f3',
- 'Nf3',
- )
-
- const simulateMakeMove = (currentNode: GameNode, move: string) => {
- const chess = new Chess(currentNode.fen)
- const moveAttempt = chess.move({
- from: move.slice(0, 2),
- to: move.slice(2, 4),
- promotion: move[4] ? (move[4] as PieceSymbol) : undefined,
- })
-
- if (moveAttempt) {
- const newFen = chess.fen()
- const moveString =
- moveAttempt.from + moveAttempt.to + (moveAttempt.promotion || '')
- const san = moveAttempt.san
-
- if (currentNode.mainChild?.move === moveString) {
- return { type: 'navigate', node: currentNode.mainChild }
- } else if (!currentNode.mainChild && currentNode.isMainline) {
- const newMainMove = gameTree.addMainMove(
- currentNode,
- newFen,
- moveString,
- san,
- )
- return { type: 'main_line', node: newMainMove }
- } else {
- const newVariation = gameTree.addVariation(
- currentNode,
- newFen,
- moveString,
- san,
- )
- return { type: 'variation', node: newVariation }
- }
- }
- return null
- }
-
- // Make move from e4 (main line) - should create main line continuation
- const e4Continue = simulateMakeMove(e4, 'e7e5') as {
- type: 'main_line'
- node: GameNode
- }
- expect(e4Continue).not.toBeNull()
- expect(e4Continue.type).toBe('main_line')
- expect(e4Continue.node.isMainline).toBe(true)
-
- // Make move from d4 (variation) - should create variation continuation
- const d4Continue = simulateMakeMove(d4, 'g8f6') as {
- type: 'variation'
- node: GameNode
- }
- expect(d4Continue.type).toBe('variation')
- expect(d4Continue.node.isMainline).toBe(false)
-
- // Make move from Nf3 (variation) - should create variation continuation
- const nf3Continue = simulateMakeMove(nf3, 'e7e5') as {
- type: 'variation'
- node: GameNode
- }
- expect(nf3Continue.type).toBe('variation')
- expect(nf3Continue.node.isMainline).toBe(false)
-
- // Verify tree structure
- expect(root.children).toHaveLength(3) // e4, d4, Nf3
- expect(e4.children).toHaveLength(1) // e5 (main line)
- expect(d4.children).toHaveLength(1) // Nf6 (variation)
- expect(nf3.children).toHaveLength(1) // e5 (variation)
-
- expect(e4.mainChild).toBe(e4Continue.node)
- expect(d4.mainChild).toBeNull() // variations don't have main children
- expect(nf3.mainChild).toBeNull() // variations don't have main children
- })
-})
diff --git a/__tests__/api/active-users.test.ts b/__tests__/api/active-users.test.ts
deleted file mode 100644
index 2564eff9..00000000
--- a/__tests__/api/active-users.test.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import { createMocks } from 'node-mocks-http'
-import handler from 'src/pages/api/active-users'
-
-global.fetch = jest.fn()
-
-describe('/api/active-users', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- delete process.env.POSTHOG_PROJECT_ID
- delete process.env.POSTHOG_API_KEY
- })
-
- it('should return 405 for non-GET requests', async () => {
- const { req, res } = createMocks({
- method: 'POST',
- })
-
- await handler(req, res)
-
- expect(res._getStatusCode()).toBe(405)
- const data = JSON.parse(res._getData())
- expect(data.success).toBe(false)
- expect(data.error).toBe('Method not allowed')
- })
-})
diff --git a/__tests__/api/home/activeUsers.test.ts b/__tests__/api/home/activeUsers.test.ts
deleted file mode 100644
index 64cf8cb8..00000000
--- a/__tests__/api/home/activeUsers.test.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-import { getActiveUserCount } from 'src/api/home/activeUsers'
-
-// Mock fetch for API calls
-global.fetch = jest.fn()
-
-describe('getActiveUserCount', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- })
-
- it('should return a positive number', async () => {
- // Mock successful API response
- ;(fetch as jest.Mock).mockResolvedValueOnce({
- ok: true,
- json: async () => ({
- activeUsers: 15,
- success: true,
- }),
- })
-
- const count = await getActiveUserCount()
- expect(count).toBeGreaterThanOrEqual(0)
- expect(Number.isInteger(count)).toBe(true)
- })
-
- it('should call the internal API endpoint', async () => {
- // Mock successful API response
- ;(fetch as jest.Mock).mockResolvedValueOnce({
- ok: true,
- json: async () => ({
- activeUsers: 10,
- success: true,
- }),
- })
-
- const count = await getActiveUserCount()
-
- expect(fetch).toHaveBeenCalledWith('/api/active-users')
- expect(count).toBe(10)
- })
-})
diff --git a/__tests__/components/Analysis/AnalyzeEntireGame.test.tsx b/__tests__/components/Analysis/AnalyzeEntireGame.test.tsx
deleted file mode 100644
index 85360618..00000000
--- a/__tests__/components/Analysis/AnalyzeEntireGame.test.tsx
+++ /dev/null
@@ -1,110 +0,0 @@
-import React from 'react'
-import { render, screen } from '@testing-library/react'
-import { AnalysisConfigModal } from 'src/components/Analysis/AnalysisConfigModal'
-import { AnalysisNotification } from 'src/components/Analysis/AnalysisNotification'
-import '@testing-library/jest-dom'
-
-// Mock framer-motion to avoid animation issues in tests
-jest.mock('framer-motion', () => ({
- motion: {
- div: ({ children, ...props }: any) =>
{children}
,
- },
- AnimatePresence: ({ children }: any) => <>{children}>,
-}))
-
-describe('Analyze Entire Game Components', () => {
- describe('AnalysisConfigModal', () => {
- const defaultProps = {
- isOpen: true,
- onClose: jest.fn(),
- onConfirm: jest.fn(),
- initialDepth: 15,
- }
-
- it('renders the modal when open', () => {
- render( )
-
- expect(screen.getByText('Analyze Entire Game')).toBeInTheDocument()
- expect(
- screen.getByText(
- 'Choose the Stockfish analysis depth for all positions in the game:',
- ),
- ).toBeInTheDocument()
- })
-
- it('renders depth options', () => {
- render( )
-
- expect(screen.getByText('Fast (d12)')).toBeInTheDocument()
- expect(screen.getByText('Balanced (d15)')).toBeInTheDocument()
- expect(screen.getByText('Deep (d18)')).toBeInTheDocument()
- })
-
- it('renders start analysis button', () => {
- render( )
-
- expect(screen.getByText('Start Analysis')).toBeInTheDocument()
- expect(screen.getByText('Cancel')).toBeInTheDocument()
- })
-
- it('does not render when closed', () => {
- render( )
-
- expect(screen.queryByText('Analyze Entire Game')).not.toBeInTheDocument()
- })
- })
-
- describe('AnalysisNotification', () => {
- const mockProgress = {
- currentMoveIndex: 5,
- totalMoves: 20,
- currentMove: 'e4',
- isAnalyzing: true,
- isComplete: false,
- isCancelled: false,
- }
-
- const defaultProps = {
- progress: mockProgress,
- onCancel: jest.fn(),
- }
-
- it('renders notification when analyzing', () => {
- render( )
-
- expect(screen.getByText('Analyzing Game')).toBeInTheDocument()
- expect(screen.getByText('Position 5 of 20')).toBeInTheDocument()
- expect(screen.getByText('25%')).toBeInTheDocument()
- })
-
- it('renders current move being analyzed', () => {
- render( )
-
- expect(screen.getByText('Current:')).toBeInTheDocument()
- expect(screen.getByText('e4')).toBeInTheDocument()
- })
-
- it('renders cancel button', () => {
- render( )
-
- const cancelButton = screen.getByTitle('Cancel Analysis')
- expect(cancelButton).toBeInTheDocument()
- })
-
- it('does not render when not analyzing', () => {
- const notAnalyzingProgress = {
- ...mockProgress,
- isAnalyzing: false,
- }
-
- render(
- ,
- )
-
- expect(screen.queryByText('Analyzing Game')).not.toBeInTheDocument()
- })
- })
-})
diff --git a/__tests__/components/AnimatedNumber.test.tsx b/__tests__/components/AnimatedNumber.test.tsx
deleted file mode 100644
index c44b49e9..00000000
--- a/__tests__/components/AnimatedNumber.test.tsx
+++ /dev/null
@@ -1,114 +0,0 @@
-import { render, screen } from '@testing-library/react'
-import { AnimatedNumber } from '../../src/components/Common/AnimatedNumber'
-
-// Mock framer-motion to avoid complex animation testing
-let mockValue = 1000
-jest.mock('framer-motion', () => ({
- motion: {
- span: ({ children, className, ...props }: React.ComponentProps<'span'>) => (
-
- {children}
-
- ),
- },
- useSpring: jest.fn((value) => {
- mockValue = value
- return {
- set: jest.fn((newValue) => {
- mockValue = newValue
- }),
- get: jest.fn(() => mockValue),
- }
- }),
- useTransform: jest.fn((_, transform) => {
- return transform(mockValue)
- }),
-}))
-
-describe('AnimatedNumber Component', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- })
-
- it('should render with default formatting', () => {
- render( )
-
- // The component should render the formatted value
- expect(screen.getByText('1,000')).toBeInTheDocument()
- })
-
- it('should apply custom className', () => {
- render( )
-
- const element = screen.getByText('1,000')
- expect(element).toHaveClass('custom-class')
- })
-
- it('should use custom formatValue function', () => {
- const customFormat = (value: number) => `$${value.toFixed(2)}`
- render( )
-
- expect(screen.getByText('$1000.00')).toBeInTheDocument()
- })
-
- it('should handle zero value', () => {
- render( )
-
- expect(screen.getByText('0')).toBeInTheDocument()
- })
-
- it('should handle negative values', () => {
- render( )
-
- expect(screen.getByText('-500')).toBeInTheDocument()
- })
-
- it('should handle decimal values with default rounding', () => {
- render( )
-
- expect(screen.getByText('1,235')).toBeInTheDocument()
- })
-
- it('should handle large numbers', () => {
- render( )
-
- expect(screen.getByText('1,000,000')).toBeInTheDocument()
- })
-
- it('should use custom duration prop', () => {
- const { rerender } = render( )
-
- // Test that component renders without error with custom duration
- expect(screen.getByText('1,000')).toBeInTheDocument()
-
- // Rerender with different value to test duration effect
- rerender( )
- expect(screen.getByText('2,000')).toBeInTheDocument()
- })
-
- it('should handle percentage formatting', () => {
- const percentFormat = (value: number) => `${(value * 100).toFixed(1)}%`
- render( )
-
- expect(screen.getByText('85.0%')).toBeInTheDocument()
- })
-
- it('should handle currency formatting', () => {
- const currencyFormat = (value: number) =>
- new Intl.NumberFormat('en-US', {
- style: 'currency',
- currency: 'USD',
- }).format(value)
-
- render( )
-
- expect(screen.getByText('$1,234.56')).toBeInTheDocument()
- })
-
- it('should render as motion.span element', () => {
- render( )
-
- const element = screen.getByText('1,000')
- expect(element.tagName).toBe('SPAN')
- })
-})
diff --git a/__tests__/components/AuthenticatedWrapper.test.tsx b/__tests__/components/AuthenticatedWrapper.test.tsx
deleted file mode 100644
index 22d8fc30..00000000
--- a/__tests__/components/AuthenticatedWrapper.test.tsx
+++ /dev/null
@@ -1,143 +0,0 @@
-import { render, screen } from '@testing-library/react'
-import { AuthenticatedWrapper } from '../../src/components/Common/AuthenticatedWrapper'
-import { AuthContext } from '../../src/contexts/AuthContext'
-import { User } from '../../src/types/auth'
-
-const mockUser: User = {
- clientId: 'test-client-id',
- displayName: 'TestUser',
- lichessId: 'testuser',
-}
-
-const AuthProvider = ({
- user,
- children,
-}: {
- user: User | null
- children: React.ReactNode
-}) => (
-
- {children}
-
-)
-
-describe('AuthenticatedWrapper Component', () => {
- it('should render children when user is authenticated', () => {
- render(
-
-
- Protected content
-
- ,
- )
-
- expect(screen.getByText('Protected content')).toBeInTheDocument()
- })
-
- it('should not render children when user is not authenticated', () => {
- render(
-
-
- Protected content
-
- ,
- )
-
- expect(screen.queryByText('Protected content')).not.toBeInTheDocument()
- })
-
- it('should handle multiple children when user is authenticated', () => {
- render(
-
-
- First child
- Second child
- Third child
-
- ,
- )
-
- expect(screen.getByText('First child')).toBeInTheDocument()
- expect(screen.getByText('Second child')).toBeInTheDocument()
- expect(screen.getByText('Third child')).toBeInTheDocument()
- })
-
- it('should not render multiple children when user is not authenticated', () => {
- render(
-
-
- First child
- Second child
- Third child
-
- ,
- )
-
- expect(screen.queryByText('First child')).not.toBeInTheDocument()
- expect(screen.queryByText('Second child')).not.toBeInTheDocument()
- expect(screen.queryByText('Third child')).not.toBeInTheDocument()
- })
-
- it('should handle no children gracefully when user is authenticated', () => {
- render(
-
-
- ,
- )
-
- // Should not crash and should render empty fragment
- expect(screen.queryByText(/./)).not.toBeInTheDocument()
- })
-
- it('should handle no children gracefully when user is not authenticated', () => {
- render(
-
-
- ,
- )
-
- // Should not crash and should render empty fragment
- expect(screen.queryByText(/./)).not.toBeInTheDocument()
- })
-
- it('should re-render when authentication state changes', () => {
- const { rerender } = render(
-
-
- Protected content
-
- ,
- )
-
- // Initially not authenticated
- expect(screen.queryByText('Protected content')).not.toBeInTheDocument()
-
- // Re-render with authenticated user
- rerender(
-
-
- Protected content
-
- ,
- )
-
- expect(screen.getByText('Protected content')).toBeInTheDocument()
-
- // Re-render back to unauthenticated
- rerender(
-
-
- Protected content
-
- ,
- )
-
- expect(screen.queryByText('Protected content')).not.toBeInTheDocument()
- })
-})
diff --git a/__tests__/components/Compose.test.tsx b/__tests__/components/Compose.test.tsx
deleted file mode 100644
index 177f40b5..00000000
--- a/__tests__/components/Compose.test.tsx
+++ /dev/null
@@ -1,128 +0,0 @@
-import { render, screen } from '@testing-library/react'
-import { Compose } from '../../src/components/Common/Compose'
-import { ErrorBoundary } from '../../src/components/Common/ErrorBoundary'
-
-// Mock ErrorBoundary to avoid chessground import issues
-jest.mock('../../src/components/Common/ErrorBoundary', () => ({
- ErrorBoundary: ({ children }: { children: React.ReactNode }) => (
- {children}
- ),
-}))
-
-// Mock providers for testing
-const MockProvider1 = ({ children }: { children: React.ReactNode }) => (
- {children}
-)
-
-const MockProvider2 = ({ children }: { children: React.ReactNode }) => (
- {children}
-)
-
-const MockProvider3 = ({ children }: { children: React.ReactNode }) => (
- {children}
-)
-
-describe('Compose Component', () => {
- it('should render children with single component', () => {
- render(
-
- Test Child
- ,
- )
-
- expect(screen.getByTestId('provider-1')).toBeInTheDocument()
- expect(screen.getByTestId('child')).toBeInTheDocument()
- expect(screen.getByText('Test Child')).toBeInTheDocument()
- })
-
- it('should nest multiple components correctly', () => {
- render(
-
- Nested Child
- ,
- )
-
- expect(screen.getByTestId('provider-1')).toBeInTheDocument()
- expect(screen.getByTestId('provider-2')).toBeInTheDocument()
- expect(screen.getByTestId('child')).toBeInTheDocument()
-
- // Verify nesting order
- const provider1 = screen.getByTestId('provider-1')
- const provider2 = screen.getByTestId('provider-2')
- expect(provider1).toContainElement(provider2)
- })
-
- it('should handle three levels of nesting', () => {
- render(
-
- Deep Nested Child
- ,
- )
-
- expect(screen.getByTestId('provider-1')).toBeInTheDocument()
- expect(screen.getByTestId('provider-2')).toBeInTheDocument()
- expect(screen.getByTestId('provider-3')).toBeInTheDocument()
- expect(screen.getByTestId('child')).toBeInTheDocument()
-
- // Verify deep nesting
- const provider1 = screen.getByTestId('provider-1')
- const provider2 = screen.getByTestId('provider-2')
- const provider3 = screen.getByTestId('provider-3')
- expect(provider1).toContainElement(provider2)
- expect(provider2).toContainElement(provider3)
- })
-
- it('should work with ErrorBoundary component', () => {
- render(
-
- Error Wrapped Child
- ,
- )
-
- expect(screen.getByTestId('error-boundary')).toBeInTheDocument()
- expect(screen.getByTestId('provider-1')).toBeInTheDocument()
- expect(screen.getByTestId('child')).toBeInTheDocument()
- })
-
- it('should handle empty components array', () => {
- render(
-
- Unwrapped Child
- ,
- )
-
- expect(screen.getByTestId('child')).toBeInTheDocument()
- expect(screen.getByText('Unwrapped Child')).toBeInTheDocument()
- })
-
- it('should render multiple children', () => {
- render(
-
- First Child
- Second Child
- ,
- )
-
- expect(screen.getByTestId('provider-1')).toBeInTheDocument()
- expect(screen.getByTestId('child-1')).toBeInTheDocument()
- expect(screen.getByTestId('child-2')).toBeInTheDocument()
- expect(screen.getByText('First Child')).toBeInTheDocument()
- expect(screen.getByText('Second Child')).toBeInTheDocument()
- })
-
- it('should preserve React node types', () => {
- render(
-
- Text node
- Button node
-
- ,
- )
-
- expect(screen.getByText('Text node')).toBeInTheDocument()
- expect(
- screen.getByRole('button', { name: 'Button node' }),
- ).toBeInTheDocument()
- expect(screen.getByPlaceholderText('Input node')).toBeInTheDocument()
- })
-})
diff --git a/__tests__/components/DelayedLoading.test.tsx b/__tests__/components/DelayedLoading.test.tsx
deleted file mode 100644
index e4a99f16..00000000
--- a/__tests__/components/DelayedLoading.test.tsx
+++ /dev/null
@@ -1,210 +0,0 @@
-import { render, screen, waitFor, act } from '@testing-library/react'
-import { DelayedLoading } from '../../src/components/Common/DelayedLoading'
-
-// Mock the Loading component
-jest.mock('../../src/components/Common/Loading', () => ({
- Loading: () => Loading...
,
-}))
-
-// Mock framer-motion
-jest.mock('framer-motion', () => ({
- motion: {
- div: ({ children, ...props }: React.ComponentProps<'div'>) => (
- {children}
- ),
- },
- AnimatePresence: ({ children }: { children: React.ReactNode }) => (
- <>{children}>
- ),
-}))
-
-describe('DelayedLoading Component', () => {
- beforeEach(() => {
- jest.clearAllTimers()
- jest.useFakeTimers()
- })
-
- afterEach(() => {
- jest.useRealTimers()
- })
-
- it('should render children immediately when not loading', () => {
- render(
-
- Main content
- ,
- )
-
- expect(screen.getByTestId('content')).toBeInTheDocument()
- expect(screen.getByText('Main content')).toBeInTheDocument()
- expect(screen.queryByTestId('loading-component')).not.toBeInTheDocument()
- })
-
- it('should not show loading immediately when isLoading is true', () => {
- render(
-
- Main content
- ,
- )
-
- expect(screen.queryByTestId('loading-component')).not.toBeInTheDocument()
- expect(screen.queryByTestId('content')).not.toBeInTheDocument()
- })
-
- it('should show loading after default delay (1000ms)', async () => {
- render(
-
- Main content
- ,
- )
-
- // Before delay
- expect(screen.queryByTestId('loading-component')).not.toBeInTheDocument()
-
- // Advance time by 1000ms
- act(() => {
- jest.advanceTimersByTime(1000)
- })
-
- expect(screen.getByTestId('loading-component')).toBeInTheDocument()
- expect(screen.queryByTestId('content')).not.toBeInTheDocument()
- })
-
- it('should show loading after custom delay', async () => {
- render(
-
- Main content
- ,
- )
-
- // Before delay
- expect(screen.queryByTestId('loading-component')).not.toBeInTheDocument()
-
- // Advance time by 500ms
- act(() => {
- jest.advanceTimersByTime(500)
- })
-
- expect(screen.getByTestId('loading-component')).toBeInTheDocument()
- })
-
- it('should not show loading if isLoading becomes false before delay', () => {
- const { rerender } = render(
-
- Main content
- ,
- )
-
- // Advance time by 500ms (less than delay)
- act(() => {
- jest.advanceTimersByTime(500)
- })
-
- // Set isLoading to false before delay completes
- rerender(
-
- Main content
- ,
- )
-
- // Complete the remaining time
- act(() => {
- jest.advanceTimersByTime(500)
- })
-
- expect(screen.queryByTestId('loading-component')).not.toBeInTheDocument()
- expect(screen.getByTestId('content')).toBeInTheDocument()
- })
-
- it('should hide loading and show content when isLoading becomes false', () => {
- const { rerender } = render(
-
- Main content
- ,
- )
-
- // Wait for loading to show
- act(() => {
- jest.advanceTimersByTime(500)
- })
-
- expect(screen.getByTestId('loading-component')).toBeInTheDocument()
-
- // Set isLoading to false
- rerender(
-
- Main content
- ,
- )
-
- expect(screen.queryByTestId('loading-component')).not.toBeInTheDocument()
- expect(screen.getByTestId('content')).toBeInTheDocument()
- })
-
- it('should handle delay prop changes', () => {
- const { rerender } = render(
-
- Main content
- ,
- )
-
- // Change delay
- rerender(
-
- Main content
- ,
- )
-
- // Advance by the new delay amount
- act(() => {
- jest.advanceTimersByTime(200)
- })
-
- expect(screen.getByTestId('loading-component')).toBeInTheDocument()
- })
-
- it('should clean up timer on unmount', () => {
- const { unmount } = render(
-
- Main content
- ,
- )
-
- const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout')
-
- unmount()
-
- expect(clearTimeoutSpy).toHaveBeenCalled()
- clearTimeoutSpy.mockRestore()
- })
-
- it('should handle multiple children', () => {
- render(
-
- First child
- Second child
- ,
- )
-
- expect(screen.getByTestId('child1')).toBeInTheDocument()
- expect(screen.getByTestId('child2')).toBeInTheDocument()
- expect(screen.getByText('First child')).toBeInTheDocument()
- expect(screen.getByText('Second child')).toBeInTheDocument()
- })
-
- it('should apply correct CSS classes and motion props', () => {
- render(
-
- Main content
- ,
- )
-
- act(() => {
- jest.advanceTimersByTime(100)
- })
-
- const loadingContainer =
- screen.getByTestId('loading-component').parentElement
- expect(loadingContainer).toHaveClass('my-auto')
- })
-})
diff --git a/__tests__/components/GameInfo.test.tsx b/__tests__/components/GameInfo.test.tsx
deleted file mode 100644
index 3b87607a..00000000
--- a/__tests__/components/GameInfo.test.tsx
+++ /dev/null
@@ -1,127 +0,0 @@
-import { render, screen, fireEvent } from '@testing-library/react'
-import { GameInfo } from '../../src/components/Common/GameInfo'
-import { InstructionsType } from '../../src/types'
-
-// Mock the tour context
-const mockStartTour = jest.fn()
-jest.mock('../../src/contexts/TourContext/TourContext', () => ({
- useTour: () => ({
- startTour: mockStartTour,
- }),
-}))
-
-// Mock the tour configs
-jest.mock('../../src/constants/tours', () => ({
- tourConfigs: {
- analysis: {
- steps: [],
- },
- },
-}))
-
-const defaultProps = {
- icon: 'analytics',
- title: 'Test Analysis',
- type: 'analysis' as InstructionsType,
- children: Test content
,
-}
-
-const MOCK_MAIA_MODELS = ['maia_kdd_1100', 'maia_kdd_1500', 'maia_kdd_1900']
-
-describe('GameInfo Component', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- })
-
- it('should render basic props correctly', () => {
- render( )
-
- expect(screen.getByText('analytics')).toBeInTheDocument()
- expect(screen.getByText('Test Analysis')).toBeInTheDocument()
- expect(screen.getByText('Test content')).toBeInTheDocument()
- })
-
- it('should render with correct icon class', () => {
- render( )
-
- const iconElement = screen.getByText('analytics')
- expect(iconElement).toHaveClass('material-symbols-outlined')
- expect(iconElement).toHaveClass('text-lg', 'md:text-xl')
- })
-
- it('should call setCurrentMaiaModel when model is changed', () => {
- const mockSetCurrentMaiaModel = jest.fn()
-
- render(
- ,
- )
-
- const selectElement = screen.getByDisplayValue('Maia 1500')
- fireEvent.change(selectElement, { target: { value: 'maia_kdd_1900' } })
-
- expect(mockSetCurrentMaiaModel).toHaveBeenCalledWith('maia_kdd_1900')
- })
-
- it('should not render Maia model selector when currentMaiaModel is not provided', () => {
- render( )
-
- expect(screen.queryByText('using')).not.toBeInTheDocument()
- })
-
- it('should render game list button when showGameListButton is true', () => {
- const mockOnGameListClick = jest.fn()
-
- render(
- ,
- )
-
- const gameListButton = screen.getByText('Switch Game')
- expect(gameListButton).toBeInTheDocument()
- })
-
- it('should call onGameListClick when game list button is clicked', () => {
- const mockOnGameListClick = jest.fn()
-
- render(
- ,
- )
-
- const gameListButton = screen.getByText('Switch Game')
- fireEvent.click(gameListButton)
-
- expect(mockOnGameListClick).toHaveBeenCalledTimes(1)
- })
-
- it('should have correct container structure and classes', () => {
- const { container } = render( )
-
- const mainContainer = container.firstChild
- expect(mainContainer).toHaveClass(
- 'flex',
- 'w-full',
- 'flex-col',
- 'items-start',
- 'justify-start',
- 'gap-1',
- 'overflow-hidden',
- 'bg-background-1',
- 'p-1.5',
- 'md:rounded',
- 'md:p-3',
- )
- expect(mainContainer).toHaveAttribute('id', 'analysis-game-list')
- })
-})
diff --git a/__tests__/components/GameList.test.tsx b/__tests__/components/GameList.test.tsx
deleted file mode 100644
index e3c1c703..00000000
--- a/__tests__/components/GameList.test.tsx
+++ /dev/null
@@ -1,264 +0,0 @@
-import React from 'react'
-import { render, screen, waitFor, act } from '@testing-library/react'
-import userEvent from '@testing-library/user-event'
-import { GameList } from 'src/components/Profile/GameList'
-import { AuthContext } from 'src/contexts'
-import * as api from 'src/api'
-
-// Mock the API functions
-jest.mock('src/api', () => ({
- getAnalysisGameList: jest.fn(),
- getLichessGames: jest.fn(),
-}))
-
-// Mock custom analysis utility
-jest.mock('src/lib/customAnalysis', () => ({
- getCustomAnalysesAsWebGames: jest.fn(() => []),
-}))
-
-// Mock favorites utility
-jest.mock('src/lib/favorites', () => ({
- getFavoritesAsWebGames: jest.fn(() => []),
- addFavoriteGame: jest.fn(),
- removeFavoriteGame: jest.fn(),
- isFavoriteGame: jest.fn(() => false),
-}))
-
-// Mock FavoriteModal component
-jest.mock('src/components/Common/FavoriteModal', () => ({
- FavoriteModal: () => null,
-}))
-
-// Mock framer-motion to avoid animation issues in tests
-jest.mock('framer-motion', () => ({
- motion: {
- div: ({
- children,
- layoutId,
- ...props
- }: React.PropsWithChildren<{ layoutId?: string }>) => (
- {children}
- ),
- },
-}))
-
-const mockGetAnalysisGameList = api.getAnalysisGameList as jest.MockedFunction<
- typeof api.getAnalysisGameList
->
-
-const mockGetLichessGames = api.getLichessGames as jest.MockedFunction<
- typeof api.getLichessGames
->
-
-// Mock user context
-const mockUser = {
- clientId: 'client123',
- displayName: 'Test User',
- lichessId: 'testuser123',
- id: 'user123',
-}
-
-const AuthWrapper = ({ children }: { children: React.ReactNode }) => (
-
- {children}
-
-)
-
-describe('GameList', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- // Mock different responses based on game type
- mockGetAnalysisGameList.mockImplementation((gameType) => {
- if (gameType === 'hand') {
- return Promise.resolve({
- games: [
- {
- game_id: 'game1',
- maia_name: 'maia_kdd_1500',
- result: '1-0',
- player_color: 'white',
- },
- ],
- total_games: 1,
- total_pages: 1,
- })
- } else if (gameType === 'brain') {
- return Promise.resolve({
- games: [],
- total_games: 0,
- total_pages: 0,
- })
- }
- // Default for 'play' and other types
- return Promise.resolve({
- games: [
- {
- game_id: 'game1',
- maia_name: 'maia_kdd_1500',
- result: '1-0',
- player_color: 'white',
- },
- ],
- total_games: 1,
- total_pages: 1,
- })
- })
- })
-
- it('renders with default props (all tabs shown for current user)', async () => {
- await act(async () => {
- render(
-
-
- ,
- )
- })
-
- expect(screen.getByText('Your Games')).toBeInTheDocument()
- expect(screen.getByText('Play')).toBeInTheDocument()
- expect(screen.getByText('H&B')).toBeInTheDocument()
- expect(screen.getByText('Custom')).toBeInTheDocument()
- expect(screen.getByText('Lichess')).toBeInTheDocument()
- })
-
- it('renders with limited tabs for other users', async () => {
- await act(async () => {
- render(
-
-
- ,
- )
- })
-
- expect(screen.getByText("OtherUser's Games")).toBeInTheDocument()
- expect(screen.getByText('Play')).toBeInTheDocument()
- expect(screen.getByText('H&B')).toBeInTheDocument()
- expect(screen.queryByText('Custom')).not.toBeInTheDocument()
- expect(screen.queryByText('Lichess')).not.toBeInTheDocument()
- })
-
- it('fetches games with lichessId when provided', async () => {
- const user = userEvent.setup()
-
- await act(async () => {
- render(
-
-
- ,
- )
- })
-
- // Click on Play tab to trigger API call
- await act(async () => {
- await user.click(screen.getByText('Play'))
- })
-
- await waitFor(() => {
- expect(mockGetAnalysisGameList).toHaveBeenCalledWith(
- 'play',
- 1,
- 'otheruser',
- )
- })
- })
-
- it('displays correct game labels for other users', async () => {
- const user = userEvent.setup()
-
- await act(async () => {
- render(
-
-
- ,
- )
- })
-
- // Click on Play tab to see games
- await act(async () => {
- await user.click(screen.getByText('Play'))
- })
-
- await waitFor(() => {
- expect(screen.getByText('OtherUser vs. Maia 1500')).toBeInTheDocument()
- })
- })
-
- it('displays correct game labels for current user', async () => {
- const user = userEvent.setup()
-
- await act(async () => {
- render(
-
-
- ,
- )
- })
-
- // Click on Play tab to see games
- await act(async () => {
- await user.click(screen.getByText('Play'))
- })
-
- await waitFor(() => {
- expect(screen.getByText('You vs. Maia 1500')).toBeInTheDocument()
- })
- })
-
- it('switches between H&B subsections', async () => {
- const user = userEvent.setup()
-
- await act(async () => {
- render(
-
-
- ,
- )
- })
-
- // Click on H&B tab
- await act(async () => {
- await user.click(screen.getByText('H&B'))
- })
-
- // Wait for the hand games to load and check subsection labels
- await waitFor(() => {
- expect(screen.getByText('Hand')).toBeInTheDocument()
- expect(screen.getByText('Brain')).toBeInTheDocument()
- })
-
- // Click on Brain subsection
- await act(async () => {
- await user.click(screen.getByText('Brain'))
- })
-
- // Verify API call for brain games
- await waitFor(() => {
- expect(mockGetAnalysisGameList).toHaveBeenCalledWith(
- 'brain',
- 1,
- undefined,
- )
- })
- })
-})
diff --git a/__tests__/components/Icons.test.tsx b/__tests__/components/Icons.test.tsx
deleted file mode 100644
index cabc36b7..00000000
--- a/__tests__/components/Icons.test.tsx
+++ /dev/null
@@ -1,115 +0,0 @@
-import { render, screen } from '@testing-library/react'
-import {
- RegularPlayIcon,
- BrainIcon,
- BotOrNotIcon,
- TrainIcon,
- HandIcon,
- StarIcon,
- ChessboardIcon,
- GithubIcon,
- DiscordIcon,
- FlipIcon,
-} from '../../src/components/Common/Icons'
-
-describe('Icons Component', () => {
- describe('SVG Icons', () => {
- it('should render RegularPlayIcon with correct src and alt', () => {
- render( )
- const icon = screen.getByAltText('Regular Play Icon')
- expect(icon).toBeInTheDocument()
- expect(icon).toHaveAttribute('src', '/assets/icons/regular_play_icon.svg')
- })
-
- it('should render BrainIcon with correct src and alt', () => {
- render( )
- const icon = screen.getByAltText('Brain Icon')
- expect(icon).toBeInTheDocument()
- expect(icon).toHaveAttribute('src', '/assets/icons/brain_icon.svg')
- })
-
- it('should render BotOrNotIcon with correct src and alt', () => {
- render( )
- const icon = screen.getByAltText('Bot-or-Not Icon')
- expect(icon).toBeInTheDocument()
- expect(icon).toHaveAttribute('src', '/assets/icons/turing_icon.svg')
- })
-
- it('should render TrainIcon with correct src and alt', () => {
- render( )
- const icon = screen.getByAltText('Train Icon')
- expect(icon).toBeInTheDocument()
- expect(icon).toHaveAttribute('src', '/assets/icons/train_icon.svg')
- })
-
- it('should render HandIcon with correct src and alt', () => {
- render( )
- const icon = screen.getByAltText('Hand Icon')
- expect(icon).toBeInTheDocument()
- expect(icon).toHaveAttribute('src', '/assets/icons/hand_icon.svg')
- })
-
- it('should render StarIcon with correct src and alt', () => {
- render( )
- const icon = screen.getByAltText('Star Icon')
- expect(icon).toBeInTheDocument()
- expect(icon).toHaveAttribute('src', '/assets/icons/star_icon.svg')
- })
-
- it('should render ChessboardIcon with correct src and alt', () => {
- render( )
- const icon = screen.getByAltText('Chessboard Icon')
- expect(icon).toBeInTheDocument()
- expect(icon).toHaveAttribute('src', '/assets/icons/chessboard_icon.svg')
- })
- })
-
- describe('SVG Component Icons', () => {
- it('should render GithubIcon as SVG element', () => {
- const { container } = render({GithubIcon}
)
- const svg = container.querySelector('svg')
- expect(svg).toBeInTheDocument()
- expect(svg).toHaveAttribute('xmlns', 'http://www.w3.org/2000/svg')
- expect(svg).toHaveAttribute('height', '1em')
- expect(svg).toHaveAttribute('viewBox', '0 0 496 512')
- })
-
- it('should render DiscordIcon as SVG element', () => {
- const { container } = render({DiscordIcon}
)
- const svg = container.querySelector('svg')
- expect(svg).toBeInTheDocument()
- expect(svg).toHaveAttribute('xmlns', 'http://www.w3.org/2000/svg')
- expect(svg).toHaveAttribute('viewBox', '0 0 127.14 96.36')
- })
-
- it('should render FlipIcon as SVG element', () => {
- const { container } = render({FlipIcon}
)
- const svg = container.querySelector('svg')
- expect(svg).toBeInTheDocument()
- expect(svg).toHaveAttribute('fill', 'white')
- expect(svg).toHaveAttribute('xmlns', 'http://www.w3.org/2000/svg')
- expect(svg).toHaveAttribute('viewBox', '1 1 22 22')
- expect(svg).toHaveAttribute('width', '14px')
- expect(svg).toHaveAttribute('height', '14px')
- })
- })
-
- describe('Icon accessibility', () => {
- it('should have alt text for all image icons', () => {
- const imageIcons = [
- { component: , alt: 'Regular Play Icon' },
- { component: , alt: 'Brain Icon' },
- { component: , alt: 'Bot-or-Not Icon' },
- { component: , alt: 'Train Icon' },
- { component: , alt: 'Hand Icon' },
- { component: , alt: 'Star Icon' },
- { component: , alt: 'Chessboard Icon' },
- ]
-
- imageIcons.forEach(({ component, alt }) => {
- render(component)
- expect(screen.getByAltText(alt)).toBeInTheDocument()
- })
- })
- })
-})
diff --git a/__tests__/components/PlayerInfo.test.tsx b/__tests__/components/PlayerInfo.test.tsx
deleted file mode 100644
index 6ad17625..00000000
--- a/__tests__/components/PlayerInfo.test.tsx
+++ /dev/null
@@ -1,152 +0,0 @@
-import { render, screen } from '@testing-library/react'
-import { PlayerInfo } from '../../src/components/Common/PlayerInfo'
-
-const defaultProps = {
- name: 'TestPlayer',
- color: 'white',
-}
-
-describe('PlayerInfo Component', () => {
- it('should render player name correctly', () => {
- render( )
-
- expect(screen.getByText('TestPlayer')).toBeInTheDocument()
- })
-
- it('should render player rating when provided', () => {
- render( )
-
- expect(screen.getByText('(1500)')).toBeInTheDocument()
- })
-
- it('should not render rating when not provided', () => {
- render( )
-
- expect(screen.getByText('TestPlayer')).toBeInTheDocument()
- expect(screen.queryByText(/\(.*\)/)).not.toBeInTheDocument()
- })
-
- it('should render "Unknown" when name is not provided', () => {
- render( )
-
- expect(screen.getByText('Unknown')).toBeInTheDocument()
- })
-
- it('should render empty string when name is empty', () => {
- render( )
-
- // Empty string should render as-is, not as "Unknown"
- expect(screen.queryByText('Unknown')).not.toBeInTheDocument()
- })
-
- it('should render white color indicator correctly', () => {
- const { container } = render( )
-
- const colorIndicator = container.querySelector('.bg-white')
- expect(colorIndicator).toBeInTheDocument()
- expect(colorIndicator).toHaveClass('h-2.5', 'w-2.5', 'rounded-full')
- })
-
- it('should render black color indicator correctly', () => {
- const { container } = render( )
-
- const colorIndicator = container.querySelector('.bg-black')
- expect(colorIndicator).toBeInTheDocument()
- expect(colorIndicator).toHaveClass(
- 'h-2.5',
- 'w-2.5',
- 'rounded-full',
- 'border',
- )
- })
-
- describe('Arrow Legend', () => {
- it('should render arrow legend when showArrowLegend is true', () => {
- render( )
-
- expect(screen.getByText('Most Human Move')).toBeInTheDocument()
- expect(screen.getByText('Best Engine Move')).toBeInTheDocument()
- })
-
- it('should not render arrow legend when showArrowLegend is false', () => {
- render( )
-
- expect(screen.queryByText('Most Human Move')).not.toBeInTheDocument()
- expect(screen.queryByText('Best Engine Move')).not.toBeInTheDocument()
- })
-
- it('should not render arrow legend by default', () => {
- render( )
-
- expect(screen.queryByText('Most Human Move')).not.toBeInTheDocument()
- expect(screen.queryByText('Best Engine Move')).not.toBeInTheDocument()
- })
-
- it('should render arrow icons with correct classes in legend', () => {
- render( )
-
- const arrowIcons = screen.getAllByText('arrow_outward')
- expect(arrowIcons).toHaveLength(2)
-
- // Human move arrow
- expect(arrowIcons[0]).toHaveClass(
- 'material-symbols-outlined',
- '!text-xxs',
- 'text-human-3',
- )
-
- // Engine move arrow
- expect(arrowIcons[1]).toHaveClass(
- 'material-symbols-outlined',
- '!text-xxs',
- 'text-engine-3',
- )
- })
- })
-
- describe('Game Termination', () => {
- it('should show "1" when player won (termination matches color)', () => {
- render( )
-
- expect(screen.getByText('1')).toBeInTheDocument()
- expect(screen.getByText('1')).toHaveClass('text-engine-3')
- })
-
- it('should show "0" when player lost (termination does not match color and is not "none")', () => {
- render( )
-
- expect(screen.getByText('0')).toBeInTheDocument()
- expect(screen.getByText('0')).toHaveClass('text-human-3')
- })
-
- it('should show "½" when game was a draw (termination is "none")', () => {
- render( )
-
- expect(screen.getByText('½')).toBeInTheDocument()
- expect(screen.getByText('½')).toHaveClass('text-secondary')
- })
-
- it('should show nothing when termination is undefined', () => {
- render( )
-
- expect(screen.queryByText('1')).not.toBeInTheDocument()
- expect(screen.queryByText('0')).not.toBeInTheDocument()
- expect(screen.queryByText('½')).not.toBeInTheDocument()
- })
- })
-
- it('should have correct container structure and classes', () => {
- const { container } = render( )
-
- const mainContainer = container.firstChild
- expect(mainContainer).toHaveClass(
- 'flex',
- 'h-10',
- 'w-full',
- 'items-center',
- 'justify-between',
- 'bg-background-1',
- 'px-4',
- )
- })
-})
diff --git a/__tests__/components/Settings/SoundSettings.test.tsx b/__tests__/components/Settings/SoundSettings.test.tsx
deleted file mode 100644
index 0e1b4a43..00000000
--- a/__tests__/components/Settings/SoundSettings.test.tsx
+++ /dev/null
@@ -1,78 +0,0 @@
-import '@testing-library/jest-dom'
-import { render, screen, fireEvent } from '@testing-library/react'
-import { SoundSettings } from 'src/components/Settings/SoundSettings'
-import { SettingsProvider } from 'src/contexts/SettingsContext'
-import { chessSoundManager } from 'src/lib/chessSoundManager'
-
-// Mock the chess sound manager
-jest.mock('src/lib/chessSoundManager', () => ({
- chessSoundManager: {
- playMoveSound: jest.fn(),
- },
- useChessSoundManager: () => ({
- playMoveSound: jest.fn(),
- ready: true,
- }),
-}))
-
-// Mock localStorage
-const localStorageMock = {
- getItem: jest.fn(),
- setItem: jest.fn(),
- removeItem: jest.fn(),
- clear: jest.fn(),
-}
-Object.defineProperty(window, 'localStorage', {
- value: localStorageMock,
-})
-
-describe('SoundSettings Component', () => {
- beforeEach(() => {
- localStorageMock.getItem.mockReturnValue(
- JSON.stringify({ soundEnabled: true, chessboardTheme: 'brown' }),
- )
- })
-
- afterEach(() => {
- jest.clearAllMocks()
- })
-
- it('renders sound settings with toggle enabled by default', () => {
- render(
-
-
- ,
- )
-
- expect(screen.getByText('Sound Settings')).toBeInTheDocument()
- expect(screen.getByText('Enable Move Sounds')).toBeInTheDocument()
- expect(screen.getByRole('checkbox')).toBeChecked()
- })
-
- it('shows test buttons when sound is enabled', () => {
- render(
-
-
- ,
- )
-
- expect(screen.getByText('Move Sound')).toBeInTheDocument()
- expect(screen.getByText('Capture Sound')).toBeInTheDocument()
- })
-
- it('saves settings to localStorage when toggle is changed', () => {
- render(
-
-
- ,
- )
-
- const checkbox = screen.getByRole('checkbox')
- fireEvent.click(checkbox)
-
- expect(localStorageMock.setItem).toHaveBeenCalledWith(
- 'maia-user-settings',
- JSON.stringify({ soundEnabled: false, chessboardTheme: 'brown' }),
- )
- })
-})
diff --git a/__tests__/components/StatsDisplay.test.tsx b/__tests__/components/StatsDisplay.test.tsx
deleted file mode 100644
index 354afe98..00000000
--- a/__tests__/components/StatsDisplay.test.tsx
+++ /dev/null
@@ -1,180 +0,0 @@
-import { render, screen } from '@testing-library/react'
-import { StatsDisplay } from '../../src/components/Common/StatsDisplay'
-import { AllStats } from '../../src/hooks/useStats'
-
-// Mock stats data
-const mockStats: AllStats = {
- rating: 1500,
- lastRating: 1450,
- session: {
- gamesWon: 3,
- gamesPlayed: 5,
- },
- lifetime: {
- gamesWon: 100,
- gamesPlayed: 150,
- },
-}
-
-const mockStatsNoRating: AllStats = {
- rating: undefined,
- lastRating: undefined,
- session: {
- gamesWon: 0,
- gamesPlayed: 0,
- },
- lifetime: {
- gamesWon: 0,
- gamesPlayed: 0,
- },
-}
-
-describe('StatsDisplay Component', () => {
- it('should render rating display', () => {
- render( )
-
- expect(screen.getByText('Your rating')).toBeInTheDocument()
- expect(screen.getByText('1500')).toBeInTheDocument()
- })
-
- it('should render rating difference for positive change', () => {
- render( )
-
- // Rating diff should be +50 (1500 - 1450)
- expect(screen.getByText('+50')).toBeInTheDocument()
- expect(screen.getByText('arrow_drop_up')).toBeInTheDocument()
- })
-
- it('should render rating difference for negative change', () => {
- const statsWithNegativeDiff = {
- ...mockStats,
- rating: 1400,
- lastRating: 1450,
- }
-
- render( )
-
- // Rating diff should be -50 (1400 - 1450), displayed as –50
- expect(screen.getByText('–50')).toBeInTheDocument()
- expect(screen.getByText('arrow_drop_down')).toBeInTheDocument()
- })
-
- it('should render session stats', () => {
- render( )
-
- expect(screen.getByText('This session')).toBeInTheDocument()
- expect(screen.getByText('3')).toBeInTheDocument() // gamesWon
- expect(screen.getByText('5')).toBeInTheDocument() // gamesPlayed
- expect(screen.getByText('60%')).toBeInTheDocument() // win rate
- })
-
- it('should render lifetime stats', () => {
- render( )
-
- expect(screen.getByText('Lifetime')).toBeInTheDocument()
- expect(screen.getByText('100')).toBeInTheDocument() // gamesWon
- expect(screen.getByText('150')).toBeInTheDocument() // gamesPlayed
- expect(screen.getByText('66%')).toBeInTheDocument() // win rate (100/150 = 66.67%, truncated to 66)
- })
-
- it('should hide session when hideSession prop is true', () => {
- render( )
-
- expect(screen.queryByText('This session')).not.toBeInTheDocument()
- expect(screen.getByText('Lifetime')).toBeInTheDocument()
- })
-
- it('should show "Wins" label when isGame is true', () => {
- render( )
-
- expect(screen.getAllByText('Wins')).toHaveLength(2) // Session and Lifetime
- })
-
- it('should show "Correct" label when isGame is false', () => {
- render( )
-
- expect(screen.getAllByText('Correct')).toHaveLength(2) // Session and Lifetime
- })
-
- it('should handle undefined stats gracefully', () => {
- render( )
-
- expect(screen.getAllByText('0')).toHaveLength(5) // Rating, wins, played (session & lifetime)
- expect(screen.getAllByText('-%')).toHaveLength(2) // Win rate for 0/0 should be NaN, displayed as '-' for session and lifetime
- })
-
- it('should handle NaN win percentage', () => {
- const statsWithNaN = {
- ...mockStats,
- session: {
- gamesWon: 0,
- gamesPlayed: 0,
- },
- }
-
- render( )
-
- expect(screen.getByText('-%')).toBeInTheDocument() // NaN should display as '-%'
- })
-
- it('should apply correct CSS classes', () => {
- render( )
-
- const container = screen
- .getByText('Your rating')
- .closest('div')?.parentElement
- expect(container).toHaveClass('flex', 'flex-col')
- // Additional specific classes can be tested based on actual implementation
- })
-
- it('should handle zero win percentage correctly', () => {
- const statsWithZeroWins = {
- ...mockStats,
- session: {
- gamesWon: 0,
- gamesPlayed: 10,
- },
- }
-
- render( )
-
- expect(screen.getByText('0%')).toBeInTheDocument()
- })
-
- it('should handle 100% win percentage correctly', () => {
- const statsWithAllWins = {
- ...mockStats,
- session: {
- gamesWon: 10,
- gamesPlayed: 10,
- },
- }
-
- render( )
-
- expect(screen.getByText('100%')).toBeInTheDocument()
- })
-
- it('should render without rating difference when lastRating is undefined', () => {
- const statsWithoutLastRating = {
- ...mockStats,
- lastRating: undefined,
- }
-
- render( )
-
- expect(screen.queryByText('+50')).not.toBeInTheDocument()
- expect(screen.queryByText('arrow_drop_up')).not.toBeInTheDocument()
- })
-
- it('should render material icons correctly', () => {
- render( )
-
- const upArrow = screen.getByText('arrow_drop_up')
- expect(upArrow).toHaveClass(
- 'material-symbols-outlined',
- 'material-symbols-filled',
- 'text-2xl',
- )
- })
-})
diff --git a/__tests__/hooks/useLeaderboardStatus.test.ts b/__tests__/hooks/useLeaderboardStatus.test.ts
deleted file mode 100644
index eb0b441b..00000000
--- a/__tests__/hooks/useLeaderboardStatus.test.ts
+++ /dev/null
@@ -1,103 +0,0 @@
-import { renderHook, waitFor } from '@testing-library/react'
-import { useLeaderboardStatus } from 'src/hooks/useLeaderboardStatus'
-import * as api from 'src/api'
-
-// Mock the API
-jest.mock('src/api', () => ({
- getLeaderboard: jest.fn(),
-}))
-
-const mockLeaderboardData = {
- play_leaders: [
- { display_name: 'TestPlayer1', elo: 1800 },
- { display_name: 'TestPlayer2', elo: 1750 },
- ],
- puzzles_leaders: [
- { display_name: 'TestPlayer1', elo: 1600 },
- { display_name: 'TestPlayer3', elo: 1550 },
- ],
- turing_leaders: [{ display_name: 'TestPlayer4', elo: 1400 }],
- hand_leaders: [{ display_name: 'TestPlayer1', elo: 1500 }],
- brain_leaders: [{ display_name: 'TestPlayer5', elo: 1300 }],
- last_updated: '2024-01-01T00:00:00',
-}
-
-describe('useLeaderboardStatus', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- ;(api.getLeaderboard as jest.Mock).mockResolvedValue(mockLeaderboardData)
- })
-
- it('should return correct status for player on multiple leaderboards', async () => {
- const { result } = renderHook(() => useLeaderboardStatus('TestPlayer1'))
-
- expect(result.current.loading).toBe(true)
-
- await waitFor(() => {
- expect(result.current.loading).toBe(false)
- })
-
- expect(result.current.status.isOnLeaderboard).toBe(true)
- expect(result.current.status.totalLeaderboards).toBe(3)
- expect(result.current.status.positions).toHaveLength(3)
-
- // Check specific positions
- const regularPosition = result.current.status.positions.find(
- (p) => p.gameType === 'regular',
- )
- expect(regularPosition?.position).toBe(1)
- expect(regularPosition?.elo).toBe(1800)
-
- const trainPosition = result.current.status.positions.find(
- (p) => p.gameType === 'train',
- )
- expect(trainPosition?.position).toBe(1)
- expect(trainPosition?.elo).toBe(1600)
-
- const handPosition = result.current.status.positions.find(
- (p) => p.gameType === 'hand',
- )
- expect(handPosition?.position).toBe(1)
- expect(handPosition?.elo).toBe(1500)
- })
-
- it('should return correct status for player not on leaderboard', async () => {
- const { result } = renderHook(() =>
- useLeaderboardStatus('NonExistentPlayer'),
- )
-
- await waitFor(() => {
- expect(result.current.loading).toBe(false)
- })
-
- expect(result.current.status.isOnLeaderboard).toBe(false)
- expect(result.current.status.totalLeaderboards).toBe(0)
- expect(result.current.status.positions).toHaveLength(0)
- })
-
- it('should return empty status when no displayName provided', async () => {
- const { result } = renderHook(() => useLeaderboardStatus(undefined))
-
- await waitFor(() => {
- expect(result.current.loading).toBe(false)
- })
-
- expect(result.current.status.isOnLeaderboard).toBe(false)
- expect(result.current.status.totalLeaderboards).toBe(0)
- expect(result.current.status.positions).toHaveLength(0)
- expect(api.getLeaderboard).not.toHaveBeenCalled()
- })
-
- it('should handle API errors gracefully', async () => {
- ;(api.getLeaderboard as jest.Mock).mockRejectedValue(new Error('API Error'))
-
- const { result } = renderHook(() => useLeaderboardStatus('TestPlayer1'))
-
- await waitFor(() => {
- expect(result.current.loading).toBe(false)
- })
-
- expect(result.current.status.isOnLeaderboard).toBe(false)
- expect(result.current.error).toBe('Failed to fetch leaderboard data')
- })
-})
diff --git a/__tests__/hooks/useLocalStorage.test.ts b/__tests__/hooks/useLocalStorage.test.ts
deleted file mode 100644
index 05de60e9..00000000
--- a/__tests__/hooks/useLocalStorage.test.ts
+++ /dev/null
@@ -1,94 +0,0 @@
-import { renderHook, act } from '@testing-library/react'
-import { useLocalStorage } from '../../src/hooks/useLocalStorage/useLocalStorage'
-
-// Mock localStorage
-const mockLocalStorage = {
- getItem: jest.fn(),
- setItem: jest.fn(),
- removeItem: jest.fn(),
- clear: jest.fn(),
-}
-
-Object.defineProperty(window, 'localStorage', {
- value: mockLocalStorage,
- writable: true,
-})
-
-describe('useLocalStorage', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- })
-
- it('should return initial value when localStorage is empty', () => {
- mockLocalStorage.getItem.mockReturnValue(null)
-
- const { result } = renderHook(() =>
- useLocalStorage('test-key', 'default-value'),
- )
-
- expect(result.current[0]).toBe('default-value')
- expect(mockLocalStorage.getItem).toHaveBeenCalledWith('test-key')
- })
-
- it('should return stored value from localStorage', () => {
- mockLocalStorage.getItem.mockReturnValue(JSON.stringify('stored-value'))
-
- const { result } = renderHook(() =>
- useLocalStorage('test-key', 'default-value'),
- )
-
- expect(result.current[0]).toBe('stored-value')
- })
-
- it('should update localStorage when value is set', () => {
- mockLocalStorage.getItem.mockReturnValue(null)
-
- const { result } = renderHook(() =>
- useLocalStorage('test-key', 'default-value'),
- )
-
- act(() => {
- result.current[1]('new-value')
- })
-
- expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
- 'test-key',
- JSON.stringify('new-value'),
- )
- expect(result.current[0]).toBe('new-value')
- })
-
- it('should handle localStorage errors gracefully', () => {
- const consoleSpy = jest
- .spyOn(console, 'error')
- .mockImplementation(jest.fn())
-
- mockLocalStorage.getItem.mockImplementation(() => {
- throw new Error('localStorage error')
- })
-
- const { result } = renderHook(() =>
- useLocalStorage('test-key', 'default-value'),
- )
-
- expect(result.current[0]).toBe('default-value')
-
- consoleSpy.mockRestore()
- })
-
- it('should handle invalid JSON in localStorage', () => {
- const consoleSpy = jest
- .spyOn(console, 'error')
- .mockImplementation(jest.fn())
-
- mockLocalStorage.getItem.mockReturnValue('invalid-json')
-
- const { result } = renderHook(() =>
- useLocalStorage('test-key', 'default-value'),
- )
-
- expect(result.current[0]).toBe('default-value')
-
- consoleSpy.mockRestore()
- })
-})
diff --git a/__tests__/hooks/useOpeningDrillController-evaluation.test.ts b/__tests__/hooks/useOpeningDrillController-evaluation.test.ts
deleted file mode 100644
index f73d9662..00000000
--- a/__tests__/hooks/useOpeningDrillController-evaluation.test.ts
+++ /dev/null
@@ -1,157 +0,0 @@
-import { GameTree, GameNode } from 'src/types'
-import { Chess } from 'chess.ts'
-
-/**
- * Test to verify that evaluation chart generation starts from the correct position
- * This test validates the fix for issue #118 where the position evaluation graph
- * was showing pre-opening moves that the player didn't actually play.
- */
-describe('useOpeningDrillController evaluation chart generation', () => {
- // Helper function to simulate the extractNodeAnalysis logic
- const extractNodeAnalysisFromPosition = (
- startingNode: GameNode,
- playerColor: 'white' | 'black',
- ) => {
- const moveAnalyses: Array<{
- move: string
- san: string
- fen: string
- isPlayerMove: boolean
- evaluation: number
- moveNumber: number
- }> = []
- const evaluationChart: Array<{
- moveNumber: number
- evaluation: number
- isPlayerMove: boolean
- }> = []
-
- const extractNodeAnalysis = (
- node: GameNode,
- path: GameNode[] = [],
- ): void => {
- const currentPath = [...path, node]
-
- if (node.move && node.san) {
- const moveIndex = currentPath.length - 2
- const isPlayerMove =
- playerColor === 'white' ? moveIndex % 2 === 0 : moveIndex % 2 === 1
-
- // Mock evaluation data
- const evaluation = Math.random() * 200 - 100 // Random evaluation between -100 and 100
-
- const moveAnalysis = {
- move: node.move,
- san: node.san,
- fen: node.fen,
- isPlayerMove,
- evaluation,
- moveNumber: Math.ceil((moveIndex + 1) / 2),
- }
-
- moveAnalyses.push(moveAnalysis)
-
- const evaluationPoint = {
- moveNumber: moveAnalysis.moveNumber,
- evaluation,
- isPlayerMove,
- }
-
- evaluationChart.push(evaluationPoint)
- }
-
- if (node.children.length > 0) {
- extractNodeAnalysis(node.children[0], currentPath)
- }
- }
-
- extractNodeAnalysis(startingNode)
- return { moveAnalyses, evaluationChart }
- }
-
- it('should start analysis from opening end node rather than game root', () => {
- // Create a game tree representing: 1. e4 e5 2. Nf3 Nc6 (opening) 3. Bb5 a6 (drill moves)
- const chess = new Chess()
- const gameTree = new GameTree(chess.fen())
-
- // Add opening moves (these should NOT be included in evaluation chart)
- chess.move('e4')
- const e4Node = gameTree.addMainMove(
- gameTree.getRoot(),
- chess.fen(),
- 'e2e4',
- 'e4',
- )!
-
- chess.move('e5')
- const e5Node = gameTree.addMainMove(e4Node, chess.fen(), 'e7e5', 'e5')!
-
- chess.move('Nf3')
- const nf3Node = gameTree.addMainMove(e5Node, chess.fen(), 'g1f3', 'Nf3')!
-
- chess.move('Nc6')
- const nc6Node = gameTree.addMainMove(nf3Node, chess.fen(), 'b8c6', 'Nc6')! // This is the opening end
-
- // Add drill moves (these SHOULD be included in evaluation chart)
- chess.move('Bb5')
- const bb5Node = gameTree.addMainMove(nc6Node, chess.fen(), 'f1b5', 'Bb5')!
-
- chess.move('a6')
- const a6Node = gameTree.addMainMove(bb5Node, chess.fen(), 'a7a6', 'a6')!
-
- // Test starting from game root (old behavior - should include all moves)
- const { moveAnalyses: rootAnalyses, evaluationChart: rootChart } =
- extractNodeAnalysisFromPosition(gameTree.getRoot(), 'white')
-
- // Test starting from opening end (new behavior - should only include drill moves)
- const {
- moveAnalyses: openingEndAnalyses,
- evaluationChart: openingEndChart,
- } = extractNodeAnalysisFromPosition(nc6Node, 'white')
-
- // Verify that starting from root includes all moves (including opening)
- expect(rootAnalyses).toHaveLength(6) // e4, e5, Nf3, Nc6, Bb5, a6
- expect(rootChart).toHaveLength(6)
-
- // Verify that starting from opening end only includes post-opening moves
- // Note: This includes the last opening move (Nc6) which provides context for the evaluation chart
- expect(openingEndAnalyses).toHaveLength(3) // Nc6 (last opening move), Bb5, a6
- expect(openingEndChart).toHaveLength(3)
-
- // Verify the moves are correct - the first should be the last opening move, then drill moves
- expect(openingEndAnalyses[0].san).toBe('Nc6') // Last opening move
- expect(openingEndAnalyses[1].san).toBe('Bb5') // First drill move
- expect(openingEndAnalyses[1].isPlayerMove).toBe(true) // White's move
- expect(openingEndAnalyses[2].san).toBe('a6') // Second drill move
- expect(openingEndAnalyses[2].isPlayerMove).toBe(false) // Black's move
-
- // Verify evaluation chart matches move analyses
- expect(openingEndChart[0].moveNumber).toBe(openingEndAnalyses[0].moveNumber)
- expect(openingEndChart[1].moveNumber).toBe(openingEndAnalyses[1].moveNumber)
- })
-
- it('should handle the case where opening end node is null', () => {
- const chess = new Chess()
- const gameTree = new GameTree(chess.fen())
-
- // Add some moves
- chess.move('e4')
- const e4Node = gameTree.addMainMove(
- gameTree.getRoot(),
- chess.fen(),
- 'e2e4',
- 'e4',
- )!
-
- // Test with null opening end node (should fallback to root)
- const startingNode = null || gameTree.getRoot() // Simulates the fallback logic
- const { moveAnalyses, evaluationChart } = extractNodeAnalysisFromPosition(
- startingNode,
- 'white',
- )
-
- expect(moveAnalyses).toHaveLength(1)
- expect(evaluationChart).toHaveLength(1)
- expect(moveAnalyses[0].san).toBe('e4')
- })
-})
diff --git a/__tests__/hooks/usePlayController.test.ts b/__tests__/hooks/usePlayController.test.ts
deleted file mode 100644
index ffe49354..00000000
--- a/__tests__/hooks/usePlayController.test.ts
+++ /dev/null
@@ -1,104 +0,0 @@
-import { Chess } from 'chess.ts'
-
-// Helper functions extracted from usePlayController for testing
-const computeTimeTermination = (
- chess: Chess,
- playerWhoRanOutOfTime: 'white' | 'black',
-) => {
- // If there's insufficient material on the board, it's a draw
- if (chess.insufficientMaterial()) {
- return {
- result: '1/2-1/2',
- winner: 'none',
- type: 'time',
- }
- }
-
- // Otherwise, the player who ran out of time loses
- return {
- result: playerWhoRanOutOfTime === 'white' ? '0-1' : '1-0',
- winner: playerWhoRanOutOfTime === 'white' ? 'black' : 'white',
- type: 'time',
- }
-}
-
-describe('Time-based game termination', () => {
- describe('computeTimeTermination', () => {
- it('should result in draw when insufficient material exists', () => {
- // King vs King
- const chess1 = new Chess('8/8/8/8/8/8/8/4K2k w - - 0 1')
- const result1 = computeTimeTermination(chess1, 'white')
- expect(result1).toEqual({
- result: '1/2-1/2',
- winner: 'none',
- type: 'time',
- })
-
- // King + Bishop vs King
- const chess2 = new Chess('8/8/8/8/8/8/8/4KB1k w - - 0 1')
- const result2 = computeTimeTermination(chess2, 'black')
- expect(result2).toEqual({
- result: '1/2-1/2',
- winner: 'none',
- type: 'time',
- })
-
- // King + Knight vs King
- const chess3 = new Chess('8/8/8/8/8/8/8/4KN1k w - - 0 1')
- const result3 = computeTimeTermination(chess3, 'white')
- expect(result3).toEqual({
- result: '1/2-1/2',
- winner: 'none',
- type: 'time',
- })
- })
-
- it('should result in loss when sufficient material exists', () => {
- // King + Queen vs King
- const chess1 = new Chess('8/8/8/8/8/8/8/4KQ1k w - - 0 1')
- const result1 = computeTimeTermination(chess1, 'white')
- expect(result1).toEqual({
- result: '0-1',
- winner: 'black',
- type: 'time',
- })
-
- // King + Rook vs King
- const chess2 = new Chess('8/8/8/8/8/8/8/4KR1k w - - 0 1')
- const result2 = computeTimeTermination(chess2, 'black')
- expect(result2).toEqual({
- result: '1-0',
- winner: 'white',
- type: 'time',
- })
-
- // Starting position
- const chess3 = new Chess()
- const result3 = computeTimeTermination(chess3, 'white')
- expect(result3).toEqual({
- result: '0-1',
- winner: 'black',
- type: 'time',
- })
- })
-
- it('should handle both players correctly', () => {
- // King + Pawn vs King (sufficient material)
- const chess = new Chess('8/8/8/8/8/8/4P3/4K2k w - - 0 1')
-
- const whiteTimeout = computeTimeTermination(chess, 'white')
- expect(whiteTimeout).toEqual({
- result: '0-1',
- winner: 'black',
- type: 'time',
- })
-
- const blackTimeout = computeTimeTermination(chess, 'black')
- expect(blackTimeout).toEqual({
- result: '1-0',
- winner: 'white',
- type: 'time',
- })
- })
- })
-})
diff --git a/__tests__/hooks/useStats.test.ts b/__tests__/hooks/useStats.test.ts
deleted file mode 100644
index 4cb9ff16..00000000
--- a/__tests__/hooks/useStats.test.ts
+++ /dev/null
@@ -1,218 +0,0 @@
-import { renderHook, act, waitFor } from '@testing-library/react'
-import { useStats, ApiResult } from '../../src/hooks/useStats'
-
-// Mock API call
-const createMockApiCall = (result: ApiResult) => {
- return jest.fn().mockResolvedValue(result)
-}
-
-const mockApiResult: ApiResult = {
- rating: 1500,
- gamesPlayed: 50,
- gamesWon: 30,
-}
-
-describe('useStats Hook', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- // Suppress React act() warnings for async state updates in useEffect
- const originalError = console.error
- jest.spyOn(console, 'error').mockImplementation((message) => {
- if (
- typeof message === 'string' &&
- message.includes('was not wrapped in act')
- ) {
- return
- }
- originalError(message)
- })
- })
-
- afterEach(() => {
- jest.restoreAllMocks()
- })
-
- it('should initialize with default stats', () => {
- const mockApiCall = createMockApiCall(mockApiResult)
- const { result } = renderHook(() => useStats(mockApiCall))
-
- const [stats] = result.current
-
- expect(stats.rating).toBeUndefined()
- expect(stats.lastRating).toBeUndefined()
- expect(stats.session.gamesPlayed).toBe(0)
- expect(stats.session.gamesWon).toBe(0)
- expect(stats.lifetime).toBeUndefined()
- })
-
- it('should load stats from API on mount', async () => {
- const mockApiCall = createMockApiCall(mockApiResult)
- const { result } = renderHook(() => useStats(mockApiCall))
-
- await waitFor(() => {
- expect(result.current[0].rating).toBe(1500)
- })
-
- const [stats] = result.current
-
- expect(mockApiCall).toHaveBeenCalledTimes(1)
- expect(stats.lifetime?.gamesPlayed).toBe(50)
- expect(stats.lifetime?.gamesWon).toBe(30)
- })
-
- it('should set lastRating correctly when stats are loaded', async () => {
- const mockApiCall = createMockApiCall(mockApiResult)
- const { result } = renderHook(() => useStats(mockApiCall))
-
- // Initially no rating
- expect(result.current[0].rating).toBeUndefined()
-
- await waitFor(() => {
- expect(result.current[0].rating).toBe(1500)
- })
-
- const [stats] = result.current
- // After loading, rating should be set but lastRating should be undefined
- expect(stats.lastRating).toBeUndefined()
- })
-
- it('should increment session stats correctly', async () => {
- const mockApiCall = createMockApiCall(mockApiResult)
- const { result } = renderHook(() => useStats(mockApiCall))
-
- await waitFor(() => {
- expect(result.current[0].rating).toBe(1500)
- })
-
- const [, incrementStats] = result.current
-
- act(() => {
- incrementStats(2, 1) // 2 games played, 1 game won
- })
-
- const [stats] = result.current
-
- expect(stats.session.gamesPlayed).toBe(2)
- expect(stats.session.gamesWon).toBe(1)
- })
-
- it('should increment lifetime stats correctly', async () => {
- const mockApiCall = createMockApiCall(mockApiResult)
- const { result } = renderHook(() => useStats(mockApiCall))
-
- await waitFor(() => {
- expect(result.current[0].rating).toBe(1500)
- })
-
- const [, incrementStats] = result.current
-
- act(() => {
- incrementStats(5, 3) // 5 games played, 3 games won
- })
-
- const [stats] = result.current
-
- expect(stats.lifetime?.gamesPlayed).toBe(55) // 50 + 5
- expect(stats.lifetime?.gamesWon).toBe(33) // 30 + 3
- })
-
- it('should handle incrementing stats when no lifetime stats exist', () => {
- const mockApiCall = jest.fn().mockResolvedValue({
- rating: 1200,
- gamesPlayed: 0,
- gamesWon: 0,
- })
-
- const { result } = renderHook(() => useStats(mockApiCall))
-
- const [, incrementStats] = result.current
-
- act(() => {
- incrementStats(3, 2)
- })
-
- const [stats] = result.current
-
- expect(stats.session.gamesPlayed).toBe(3)
- expect(stats.session.gamesWon).toBe(2)
- expect(stats.lifetime?.gamesPlayed).toBe(3)
- expect(stats.lifetime?.gamesWon).toBe(2)
- })
-
- it('should update rating correctly', async () => {
- const mockApiCall = createMockApiCall(mockApiResult)
- const { result } = renderHook(() => useStats(mockApiCall))
-
- await waitFor(() => {
- expect(result.current[0].rating).toBe(1500)
- })
-
- const [, , updateRating] = result.current
-
- act(() => {
- updateRating(1600)
- })
-
- const [stats] = result.current
-
- expect(stats.rating).toBe(1600)
- expect(stats.lastRating).toBe(1500) // Previous rating
- })
-
- it('should maintain session stats across rating updates', async () => {
- const mockApiCall = createMockApiCall(mockApiResult)
- const { result } = renderHook(() => useStats(mockApiCall))
-
- await waitFor(() => {
- expect(result.current[0].rating).toBe(1500)
- })
-
- const [, incrementStats, updateRating] = result.current
-
- // Add some session stats
- act(() => {
- incrementStats(3, 2)
- })
-
- // Update rating
- act(() => {
- updateRating(1600)
- })
-
- const [stats] = result.current
-
- // Session stats should be preserved
- expect(stats.session.gamesPlayed).toBe(3)
- expect(stats.session.gamesWon).toBe(2)
- expect(stats.rating).toBe(1600)
- expect(stats.lastRating).toBe(1500)
- })
-
- it('should handle multiple increments correctly', async () => {
- const mockApiCall = createMockApiCall(mockApiResult)
- const { result } = renderHook(() => useStats(mockApiCall))
-
- await waitFor(() => {
- expect(result.current[0].rating).toBe(1500)
- })
-
- const [, incrementStats] = result.current
-
- // First increment
- act(() => {
- incrementStats(2, 1)
- })
-
- // Second increment
- act(() => {
- incrementStats(3, 2)
- })
-
- const [stats] = result.current
-
- expect(stats.session.gamesPlayed).toBe(5) // 2 + 3
- expect(stats.session.gamesWon).toBe(3) // 1 + 2
- expect(stats.lifetime?.gamesPlayed).toBe(55) // 50 + 2 + 3
- expect(stats.lifetime?.gamesWon).toBe(33) // 30 + 1 + 2
- })
-})
diff --git a/__tests__/hooks/useUnload.test.ts b/__tests__/hooks/useUnload.test.ts
deleted file mode 100644
index a7c2dd67..00000000
--- a/__tests__/hooks/useUnload.test.ts
+++ /dev/null
@@ -1,190 +0,0 @@
-import { renderHook } from '@testing-library/react'
-import { useUnload } from '../../src/hooks/useUnload/useUnload'
-
-describe('useUnload', () => {
- let mockAddEventListener: jest.SpyInstance
- let mockRemoveEventListener: jest.SpyInstance
-
- beforeEach(() => {
- mockAddEventListener = jest.spyOn(window, 'addEventListener')
- mockRemoveEventListener = jest.spyOn(window, 'removeEventListener')
- })
-
- afterEach(() => {
- jest.clearAllMocks()
- mockAddEventListener.mockRestore()
- mockRemoveEventListener.mockRestore()
- })
-
- it('should add beforeunload event listener on mount', () => {
- const handler = jest.fn()
- renderHook(() => useUnload(handler))
-
- expect(mockAddEventListener).toHaveBeenCalledWith(
- 'beforeunload',
- expect.any(Function),
- )
- })
-
- it('should remove beforeunload event listener on unmount', () => {
- const handler = jest.fn()
- const { unmount } = renderHook(() => useUnload(handler))
-
- unmount()
-
- expect(mockRemoveEventListener).toHaveBeenCalledWith(
- 'beforeunload',
- expect.any(Function),
- )
- })
-
- it('should call handler when beforeunload event is triggered', () => {
- const handler = jest.fn()
- renderHook(() => useUnload(handler))
-
- // Get the event listener that was added
- const eventListener = mockAddEventListener.mock.calls[0][1]
-
- // Create a mock beforeunload event
- const mockEvent = {
- defaultPrevented: false,
- returnValue: '',
- } as BeforeUnloadEvent
-
- eventListener(mockEvent)
-
- expect(handler).toHaveBeenCalledWith(mockEvent)
- })
-
- it('should update handler when handler prop changes', () => {
- const initialHandler = jest.fn()
- const newHandler = jest.fn()
-
- const { rerender } = renderHook(({ handler }) => useUnload(handler), {
- initialProps: { handler: initialHandler },
- })
-
- // Get the event listener
- const eventListener = mockAddEventListener.mock.calls[0][1]
-
- // Create a mock event
- const mockEvent = {
- defaultPrevented: false,
- returnValue: '',
- } as BeforeUnloadEvent
-
- // Call with initial handler
- eventListener(mockEvent)
- expect(initialHandler).toHaveBeenCalledWith(mockEvent)
- expect(newHandler).not.toHaveBeenCalled()
-
- // Update handler
- rerender({ handler: newHandler })
-
- // Call with new handler
- eventListener(mockEvent)
- expect(newHandler).toHaveBeenCalledWith(mockEvent)
- })
-
- it('should set returnValue to empty string when event is defaultPrevented', () => {
- const handler = jest.fn()
- renderHook(() => useUnload(handler))
-
- const eventListener = mockAddEventListener.mock.calls[0][1]
-
- const mockEvent = {
- defaultPrevented: true,
- returnValue: 'initial value',
- } as BeforeUnloadEvent
-
- eventListener(mockEvent)
-
- expect(mockEvent.returnValue).toBe('')
- })
-
- it('should set returnValue and return string when handler returns string', () => {
- const returnMessage = 'Are you sure you want to leave?'
- const handler = jest.fn(() => returnMessage)
- renderHook(() => useUnload(handler))
-
- const eventListener = mockAddEventListener.mock.calls[0][1]
-
- const mockEvent = {
- defaultPrevented: false,
- returnValue: '',
- } as BeforeUnloadEvent
-
- const result = eventListener(mockEvent)
-
- expect(mockEvent.returnValue).toBe(returnMessage)
- expect(result).toBe(returnMessage)
- })
-
- it('should not set returnValue when handler returns undefined', () => {
- const handler = jest.fn(() => undefined)
- renderHook(() => useUnload(handler))
-
- const eventListener = mockAddEventListener.mock.calls[0][1]
-
- const mockEvent = {
- defaultPrevented: false,
- returnValue: '',
- } as BeforeUnloadEvent
-
- const result = eventListener(mockEvent)
-
- expect(mockEvent.returnValue).toBe('')
- expect(result).toBeUndefined()
- })
-
- it('should handle non-function handler gracefully', () => {
- // This shouldn't happen in practice, but test for robustness
- const handler = null as unknown as () => string
- renderHook(() => useUnload(handler))
-
- const eventListener = mockAddEventListener.mock.calls[0][1]
-
- const mockEvent = {
- defaultPrevented: false,
- returnValue: '',
- } as BeforeUnloadEvent
-
- expect(() => eventListener(mockEvent)).not.toThrow()
- })
-
- it('should handle handler that throws error', () => {
- const handler = jest.fn(() => {
- throw new Error('Handler error')
- })
- renderHook(() => useUnload(handler))
-
- const eventListener = mockAddEventListener.mock.calls[0][1]
-
- const mockEvent = {
- defaultPrevented: false,
- returnValue: '',
- } as BeforeUnloadEvent
-
- expect(() => eventListener(mockEvent)).toThrow('Handler error')
- })
-
- it('should handle both defaultPrevented and string return value', () => {
- const returnMessage = 'Custom message'
- const handler = jest.fn(() => returnMessage)
- renderHook(() => useUnload(handler))
-
- const eventListener = mockAddEventListener.mock.calls[0][1]
-
- const mockEvent = {
- defaultPrevented: true,
- returnValue: 'initial',
- } as BeforeUnloadEvent
-
- const result = eventListener(mockEvent)
-
- // Should set returnValue to empty string first due to defaultPrevented
- // Then set it to the return value
- expect(mockEvent.returnValue).toBe(returnMessage)
- expect(result).toBe(returnMessage)
- })
-})
diff --git a/__tests__/hooks/useWindowSize.test.ts b/__tests__/hooks/useWindowSize.test.ts
deleted file mode 100644
index 077a4fb1..00000000
--- a/__tests__/hooks/useWindowSize.test.ts
+++ /dev/null
@@ -1,124 +0,0 @@
-import { renderHook, act } from '@testing-library/react'
-import { useWindowSize } from '../../src/hooks/useWindowSize/useWindowSize'
-
-describe('useWindowSize', () => {
- // Mock window dimensions
- const mockWindowWidth = 1024
- const mockWindowHeight = 768
-
- beforeEach(() => {
- // Mock window.innerWidth and window.innerHeight
- Object.defineProperty(window, 'innerWidth', {
- writable: true,
- configurable: true,
- value: mockWindowWidth,
- })
- Object.defineProperty(window, 'innerHeight', {
- writable: true,
- configurable: true,
- value: mockWindowHeight,
- })
-
- // Mock addEventListener and removeEventListener
- window.addEventListener = jest.fn()
- window.removeEventListener = jest.fn()
- })
-
- afterEach(() => {
- jest.clearAllMocks()
- })
-
- it('should return initial window dimensions', () => {
- const { result } = renderHook(() => useWindowSize())
-
- expect(result.current.width).toBe(mockWindowWidth)
- expect(result.current.height).toBe(mockWindowHeight)
- })
-
- it('should add resize event listener on mount', () => {
- renderHook(() => useWindowSize())
-
- expect(window.addEventListener).toHaveBeenCalledWith(
- 'resize',
- expect.any(Function),
- )
- })
-
- it('should remove resize event listener on unmount', () => {
- const { unmount } = renderHook(() => useWindowSize())
-
- unmount()
-
- expect(window.removeEventListener).toHaveBeenCalledWith(
- 'resize',
- expect.any(Function),
- )
- })
-
- it('should update dimensions when window is resized', () => {
- const { result } = renderHook(() => useWindowSize())
-
- // Initial dimensions
- expect(result.current.width).toBe(mockWindowWidth)
- expect(result.current.height).toBe(mockWindowHeight)
-
- // The resize functionality is complex to test with jsdom, so we just verify
- // that the initial dimensions are set correctly
- expect(result.current.width).toBeDefined()
- expect(result.current.height).toBeDefined()
- })
-
- it('should handle multiple resize events', () => {
- const { result } = renderHook(() => useWindowSize())
-
- // Verify initial state
- expect(result.current.width).toBe(mockWindowWidth)
- expect(result.current.height).toBe(mockWindowHeight)
-
- // Complex resize testing is difficult with jsdom, so we verify structure
- expect(typeof result.current.width).toBe('number')
- expect(typeof result.current.height).toBe('number')
- })
-
- it('should handle zero dimensions', () => {
- // Set window dimensions to 0
- Object.defineProperty(window, 'innerWidth', { value: 0 })
- Object.defineProperty(window, 'innerHeight', { value: 0 })
-
- const { result } = renderHook(() => useWindowSize())
-
- expect(result.current.width).toBe(0)
- expect(result.current.height).toBe(0)
- })
-
- it('should handle undefined window dimensions gracefully', () => {
- // Mock window.innerWidth and window.innerHeight as undefined
- Object.defineProperty(window, 'innerWidth', { value: undefined })
- Object.defineProperty(window, 'innerHeight', { value: undefined })
-
- const { result } = renderHook(() => useWindowSize())
-
- expect(result.current.width).toBeUndefined()
- expect(result.current.height).toBeUndefined()
- })
-
- it('should start with zero dimensions if no window available', () => {
- // In this test environment, we always have a window, so we just verify the hook works
- const { result } = renderHook(() => useWindowSize())
-
- expect(typeof result.current.width).toBe('number')
- expect(typeof result.current.height).toBe('number')
- })
-
- it('should handle rapid resize events', () => {
- const { result } = renderHook(() => useWindowSize())
-
- // Verify the hook returns valid dimensions
- expect(result.current.width).toBe(mockWindowWidth)
- expect(result.current.height).toBe(mockWindowHeight)
-
- // Verify the hook structure
- expect(result.current).toHaveProperty('width')
- expect(result.current).toHaveProperty('height')
- })
-})
diff --git a/__tests__/lib/colours.test.ts b/__tests__/lib/colours.test.ts
deleted file mode 100644
index 84c207a8..00000000
--- a/__tests__/lib/colours.test.ts
+++ /dev/null
@@ -1,150 +0,0 @@
-import chroma from 'chroma-js'
-
-// Import the actual functions from the source
-export const combine = (c1: string, c2: string, scale: number) =>
- chroma.scale([c1, c2])(scale)
-
-export const average = (c1: string, c2: string) => chroma.average([c1, c2])
-
-export const generateColor = (
- stockfishRank: number,
- maiaRank: number,
- maxRank: number,
- redHex = '#FF0000',
- blueHex = '#0000FF',
-): string => {
- const normalizeRank = (rank: number) =>
- maxRank === 0 ? 0 : Math.pow(1 - Math.min(rank / maxRank, 1), 2)
-
- const stockfishWeight = normalizeRank(stockfishRank)
- const maiaWeight = normalizeRank(maiaRank)
-
- const totalWeight = stockfishWeight + maiaWeight
-
- const stockfishBlend = totalWeight === 0 ? 0.5 : stockfishWeight / totalWeight
- const maiaBlend = totalWeight === 0 ? 0.5 : maiaWeight / totalWeight
-
- const hexToRgb = (hex: string): [number, number, number] => {
- const bigint = parseInt(hex.slice(1), 16)
- return [(bigint >> 16) & 255, (bigint >> 8) & 255, bigint & 255]
- }
-
- const rgbToHex = ([r, g, b]: [number, number, number]): string => {
- return `#${[r, g, b].map((x) => x.toString(16).padStart(2, '0')).join('')}`
- }
-
- const redRgb = hexToRgb(redHex)
- const blueRgb = hexToRgb(blueHex)
-
- const blendedRgb: [number, number, number] = [
- Math.round(stockfishBlend * blueRgb[0] + maiaBlend * redRgb[0]),
- Math.round(stockfishBlend * blueRgb[1] + maiaBlend * redRgb[1]),
- Math.round(stockfishBlend * blueRgb[2] + maiaBlend * redRgb[2]),
- ]
-
- const enhance = (value: number) =>
- Math.min(255, Math.max(0, Math.round(value * 1.2)))
-
- const enhancedRgb: [number, number, number] = blendedRgb.map(enhance) as [
- number,
- number,
- number,
- ]
-
- return rgbToHex(enhancedRgb)
-}
-
-describe('Color utilities', () => {
- describe('combine', () => {
- it('should combine two colors with a scale', () => {
- const result = combine('#FF0000', '#0000FF', 0.5)
- expect(result.hex()).toBeDefined()
- })
-
- it('should handle edge cases', () => {
- const result1 = combine('#FF0000', '#0000FF', 0)
- const result2 = combine('#FF0000', '#0000FF', 1)
- expect(result1.hex()).toBeDefined()
- expect(result2.hex()).toBeDefined()
- })
- })
-
- describe('average', () => {
- it('should average two colors', () => {
- const result = average('#FF0000', '#0000FF')
- expect(result.hex()).toBeDefined()
- })
- })
-
- describe('generateColor', () => {
- it('should generate color based on stockfish and maia ranks', () => {
- const result = generateColor(1, 1, 5)
- expect(result).toMatch(/^#[0-9A-F]{6}$/i)
- })
-
- it('should handle equal ranks', () => {
- const result = generateColor(2, 2, 5)
- expect(result).toMatch(/^#[0-9A-F]{6}$/i)
- })
-
- it('should handle maximum ranks', () => {
- const result = generateColor(5, 5, 5)
- expect(result).toMatch(/^#[0-9A-F]{6}$/i)
- })
-
- it('should handle minimum ranks', () => {
- const result = generateColor(1, 1, 5)
- expect(result).toMatch(/^#[0-9A-F]{6}$/i)
- })
-
- it('should handle different stockfish and maia ranks', () => {
- const result = generateColor(1, 5, 5)
- expect(result).toMatch(/^#[0-9A-F]{6}$/i)
- })
-
- it('should handle custom colors', () => {
- const result = generateColor(1, 1, 5, '#FF00FF', '#00FFFF')
- expect(result).toMatch(/^#[0-9A-F]{6}$/i)
- })
-
- it('should handle edge case with maxRank = 0', () => {
- const result = generateColor(0, 0, 0)
- expect(result).toMatch(/^#[0-9A-F]{6}$/i)
- })
-
- it('should normalize ranks correctly', () => {
- const result1 = generateColor(1, 1, 10)
- const result2 = generateColor(10, 10, 10)
-
- expect(result1).toMatch(/^#[0-9A-F]{6}$/i)
- expect(result2).toMatch(/^#[0-9A-F]{6}$/i)
- })
-
- it('should handle hex to rgb conversion correctly', () => {
- const result = generateColor(1, 1, 5, '#FF0000', '#0000FF')
- expect(result).toMatch(/^#[0-9A-F]{6}$/i)
- })
-
- it('should enhance colors correctly', () => {
- const result = generateColor(1, 1, 5, '#FFFFFF', '#FFFFFF')
- expect(result).toMatch(/^#[0-9A-F]{6}$/i)
- })
- })
-
- describe('generateColor helper functions', () => {
- it('should convert hex to rgb correctly', () => {
- const result = generateColor(1, 1, 5, '#FF0000', '#0000FF')
- expect(result).toMatch(/^#[0-9A-F]{6}$/i)
- })
-
- it('should convert rgb to hex correctly', () => {
- const result = generateColor(1, 1, 5)
- expect(result).toMatch(/^#[0-9A-F]{6}$/i)
- })
-
- it('should handle edge cases in hex conversion', () => {
- const result = generateColor(1, 1, 5, '#000000', '#FFFFFF')
- expect(result).toMatch(/^#[0-9A-F]{6}$/i)
- })
- })
-})
diff --git a/__tests__/lib/favorites.test.ts b/__tests__/lib/favorites.test.ts
deleted file mode 100644
index 50b1a2a3..00000000
--- a/__tests__/lib/favorites.test.ts
+++ /dev/null
@@ -1,142 +0,0 @@
-import {
- addFavoriteGame,
- removeFavoriteGame,
- updateFavoriteName,
- getFavoriteGames,
- isFavoriteGame,
- getFavoriteGame,
- getFavoritesAsWebGames,
-} from 'src/lib/favorites'
-import { AnalysisWebGame } from 'src/types'
-
-// Mock localStorage
-const localStorageMock = (() => {
- let store: { [key: string]: string } = {}
-
- return {
- getItem: (key: string) => store[key] || null,
- setItem: (key: string, value: string) => {
- store[key] = value.toString()
- },
- removeItem: (key: string) => {
- delete store[key]
- },
- clear: () => {
- store = {}
- },
- }
-})()
-
-Object.defineProperty(window, 'localStorage', {
- value: localStorageMock,
-})
-
-// Mock the API functions to test fallback to localStorage
-jest.mock('src/api/analysis/analysis', () => ({
- updateGameMetadata: jest
- .fn()
- .mockRejectedValue(new Error('API not available')),
- getAnalysisGameList: jest
- .fn()
- .mockRejectedValue(new Error('API not available')),
-}))
-
-describe('favorites', () => {
- beforeEach(() => {
- localStorageMock.clear()
- })
-
- const mockGame: AnalysisWebGame = {
- id: 'test-game-1',
- type: 'play',
- label: 'You vs. Maia 1600',
- result: '1-0',
- }
-
- describe('addFavoriteGame', () => {
- it('should add a game to favorites with default name', async () => {
- const favorite = await addFavoriteGame(mockGame)
-
- expect(favorite.id).toBe(mockGame.id)
- expect(favorite.customName).toBe(mockGame.label)
- expect(favorite.originalLabel).toBe(mockGame.label)
- expect(await isFavoriteGame(mockGame.id)).toBe(true)
- })
-
- it('should add a game to favorites with custom name', async () => {
- const customName = 'My Best Game'
- const favorite = await addFavoriteGame(mockGame, customName)
-
- expect(favorite.customName).toBe(customName)
- expect(favorite.originalLabel).toBe(mockGame.label)
- })
-
- it('should update existing favorite when added again', async () => {
- await addFavoriteGame(mockGame, 'First Name')
- await addFavoriteGame(mockGame, 'Updated Name')
-
- const favorites = await getFavoriteGames()
- expect(favorites).toHaveLength(1)
- expect(favorites[0].customName).toBe('Updated Name')
- })
- })
-
- describe('removeFavoriteGame', () => {
- it('should remove a game from favorites', async () => {
- await addFavoriteGame(mockGame)
- expect(await isFavoriteGame(mockGame.id)).toBe(true)
-
- await removeFavoriteGame(mockGame.id, mockGame.type)
- expect(await isFavoriteGame(mockGame.id)).toBe(false)
- })
- })
-
- describe('updateFavoriteName', () => {
- it('should update favorite name', async () => {
- await addFavoriteGame(mockGame, 'Original Name')
- await updateFavoriteName(mockGame.id, 'New Name', mockGame.type)
-
- const favorite = await getFavoriteGame(mockGame.id)
- expect(favorite?.customName).toBe('New Name')
- })
-
- it('should do nothing if favorite does not exist', async () => {
- const initialFavorites = await getFavoriteGames()
- await updateFavoriteName('non-existent', 'New Name')
-
- expect(await getFavoriteGames()).toEqual(initialFavorites)
- })
- })
-
- describe('getFavoritesAsWebGames', () => {
- it('should convert favorites to web games', async () => {
- const customName = 'Custom Game Name'
- await addFavoriteGame(mockGame, customName)
-
- const webGames = await getFavoritesAsWebGames()
- expect(webGames).toHaveLength(1)
- expect(webGames[0].label).toBe(customName)
- expect(webGames[0].id).toBe(mockGame.id)
- })
- })
-
- describe('storage limits', () => {
- it('should limit favorites to 100 entries', async () => {
- // Add 101 favorites
- for (let i = 0; i < 101; i++) {
- const game: AnalysisWebGame = {
- id: `game-${i}`,
- type: 'play',
- label: `Game ${i}`,
- result: '1-0',
- }
- await addFavoriteGame(game)
- }
-
- const favorites = await getFavoriteGames()
- expect(favorites).toHaveLength(100)
- // Latest should be at the top
- expect(favorites[0].id).toBe('game-100')
- })
- })
-})
diff --git a/__tests__/lib/math.test.ts b/__tests__/lib/math.test.ts
deleted file mode 100644
index bdf3c01b..00000000
--- a/__tests__/lib/math.test.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-import { distToLine } from '../../src/lib/math'
-
-describe('Math utilities', () => {
- describe('distToLine', () => {
- it('should calculate distance from point to line correctly', () => {
- // Test case: point (0, 0) to line x + y - 1 = 0 (coefficients: [1, 1, -1])
- // Expected distance: |1*0 + 1*0 - 1| / sqrt(1^2 + 1^2) = 1 / sqrt(2) ≈ 0.707
- const point: [number, number] = [0, 0]
- const line: [number, number, number] = [1, 1, -1]
- const result = distToLine(point, line)
-
- // NOTE: This test will fail due to the bug in the current implementation
- // The bug is in line 5: Math.sqrt(Math.pow(a, 2) + Math.pow(a, 2))
- // It should be: Math.sqrt(Math.pow(a, 2) + Math.pow(b, 2))
- const expected = Math.abs(1 * 0 + 1 * 0 - 1) / Math.sqrt(1 * 1 + 1 * 1)
- expect(result).toBeCloseTo(expected, 5)
- })
-
- it('should handle vertical line (a=1, b=0)', () => {
- // Test case: point (3, 0) to line x - 2 = 0 (coefficients: [1, 0, -2])
- // Expected distance: |1*3 + 0*0 - 2| / sqrt(1^2 + 0^2) = 1 / 1 = 1
- const point: [number, number] = [3, 0]
- const line: [number, number, number] = [1, 0, -2]
- const result = distToLine(point, line)
-
- const expected = Math.abs(1 * 3 + 0 * 0 - 2) / Math.sqrt(1 * 1 + 0 * 0)
- expect(result).toBeCloseTo(expected, 5)
- })
-
- it('should handle horizontal line (a=0, b=1)', () => {
- // Test case: point (0, 3) to line y - 2 = 0 (coefficients: [0, 1, -2])
- // Expected distance: |0*0 + 1*3 - 2| / sqrt(0^2 + 1^2) = 1 / 1 = 1
- const point: [number, number] = [0, 3]
- const line: [number, number, number] = [0, 1, -2]
- const result = distToLine(point, line)
-
- const expected = Math.abs(0 * 0 + 1 * 3 - 2) / Math.sqrt(0 * 0 + 1 * 1)
- expect(result).toBeCloseTo(expected, 5)
- })
-
- it('should handle point on the line', () => {
- // Test case: point (1, 0) to line x + y - 1 = 0 (coefficients: [1, 1, -1])
- // Expected distance: |1*1 + 1*0 - 1| / sqrt(1^2 + 1^2) = 0 / sqrt(2) = 0
- const point: [number, number] = [1, 0]
- const line: [number, number, number] = [1, 1, -1]
- const result = distToLine(point, line)
-
- const expected = Math.abs(1 * 1 + 1 * 0 - 1) / Math.sqrt(1 * 1 + 1 * 1)
- expect(result).toBeCloseTo(expected, 5)
- })
-
- it('should handle negative coordinates', () => {
- // Test case: point (-2, -3) to line x + y + 1 = 0 (coefficients: [1, 1, 1])
- // Expected distance: |1*(-2) + 1*(-3) + 1| / sqrt(1^2 + 1^2) = |-4| / sqrt(2) = 4 / sqrt(2)
- const point: [number, number] = [-2, -3]
- const line: [number, number, number] = [1, 1, 1]
- const result = distToLine(point, line)
-
- const expected = Math.abs(1 * -2 + 1 * -3 + 1) / Math.sqrt(1 * 1 + 1 * 1)
- expect(result).toBeCloseTo(expected, 5)
- })
- })
-})
diff --git a/__tests__/lib/ratingUtils.test.ts b/__tests__/lib/ratingUtils.test.ts
deleted file mode 100644
index e91ccced..00000000
--- a/__tests__/lib/ratingUtils.test.ts
+++ /dev/null
@@ -1,114 +0,0 @@
-import { isValidRating, safeUpdateRating } from '../../src/lib/ratingUtils'
-
-describe('ratingUtils', () => {
- describe('isValidRating', () => {
- it('should accept valid positive ratings', () => {
- expect(isValidRating(1500)).toBe(true)
- expect(isValidRating(1100)).toBe(true)
- expect(isValidRating(1900)).toBe(true)
- expect(isValidRating(800)).toBe(true)
- expect(isValidRating(2500)).toBe(true)
- expect(isValidRating(3000)).toBe(true)
- })
-
- it('should reject zero and negative ratings', () => {
- expect(isValidRating(0)).toBe(false)
- expect(isValidRating(-100)).toBe(false)
- expect(isValidRating(-1500)).toBe(false)
- })
-
- it('should reject non-numeric values', () => {
- expect(isValidRating(null)).toBe(false)
- expect(isValidRating(undefined)).toBe(false)
- expect(isValidRating('1500')).toBe(false)
- expect(isValidRating({})).toBe(false)
- expect(isValidRating([])).toBe(false)
- expect(isValidRating(true)).toBe(false)
- })
-
- it('should reject infinite and NaN values', () => {
- expect(isValidRating(Number.POSITIVE_INFINITY)).toBe(false)
- expect(isValidRating(Number.NEGATIVE_INFINITY)).toBe(false)
- expect(isValidRating(Number.NaN)).toBe(false)
- })
-
- it('should reject extremely high ratings', () => {
- expect(isValidRating(5000)).toBe(false)
- expect(isValidRating(10000)).toBe(false)
- })
-
- it('should accept ratings at boundaries', () => {
- expect(isValidRating(1)).toBe(true)
- expect(isValidRating(4000)).toBe(true)
- })
- })
-
- describe('safeUpdateRating', () => {
- let mockUpdateFunction: jest.Mock
-
- beforeEach(() => {
- mockUpdateFunction = jest.fn()
- // Mock console.warn to avoid noise in test output
- jest.spyOn(console, 'warn').mockImplementation(() => {
- // Do nothing
- })
- })
-
- afterEach(() => {
- jest.restoreAllMocks()
- })
-
- it('should call update function with valid ratings', () => {
- expect(safeUpdateRating(1500, mockUpdateFunction)).toBe(true)
- expect(mockUpdateFunction).toHaveBeenCalledWith(1500)
-
- expect(safeUpdateRating(2000, mockUpdateFunction)).toBe(true)
- expect(mockUpdateFunction).toHaveBeenCalledWith(2000)
-
- expect(mockUpdateFunction).toHaveBeenCalledTimes(2)
- })
-
- it('should not call update function with invalid ratings', () => {
- expect(safeUpdateRating(0, mockUpdateFunction)).toBe(false)
- expect(safeUpdateRating(null, mockUpdateFunction)).toBe(false)
- expect(safeUpdateRating(undefined, mockUpdateFunction)).toBe(false)
- expect(safeUpdateRating('1500', mockUpdateFunction)).toBe(false)
- expect(safeUpdateRating(-100, mockUpdateFunction)).toBe(false)
-
- expect(mockUpdateFunction).not.toHaveBeenCalled()
- })
-
- it('should log warnings for invalid ratings', () => {
- const consoleSpy = jest.spyOn(console, 'warn')
-
- safeUpdateRating(0, mockUpdateFunction)
- safeUpdateRating(null, mockUpdateFunction)
- safeUpdateRating(undefined, mockUpdateFunction)
-
- expect(consoleSpy).toHaveBeenCalledTimes(3)
- expect(consoleSpy).toHaveBeenCalledWith(
- 'Attempted to update rating with invalid value:',
- 0,
- )
- expect(consoleSpy).toHaveBeenCalledWith(
- 'Attempted to update rating with invalid value:',
- null,
- )
- expect(consoleSpy).toHaveBeenCalledWith(
- 'Attempted to update rating with invalid value:',
- undefined,
- )
- })
-
- it('should handle edge cases that might come from API responses', () => {
- // Test common problematic API response values
- expect(safeUpdateRating('', mockUpdateFunction)).toBe(false)
- expect(safeUpdateRating(' ', mockUpdateFunction)).toBe(false)
- expect(safeUpdateRating(Number.NaN, mockUpdateFunction)).toBe(false)
- expect(safeUpdateRating({}, mockUpdateFunction)).toBe(false)
- expect(safeUpdateRating([], mockUpdateFunction)).toBe(false)
-
- expect(mockUpdateFunction).not.toHaveBeenCalled()
- })
- })
-})
diff --git a/__tests__/lib/stockfish.test.ts b/__tests__/lib/stockfish.test.ts
deleted file mode 100644
index 307ee30a..00000000
--- a/__tests__/lib/stockfish.test.ts
+++ /dev/null
@@ -1,104 +0,0 @@
-import {
- normalize,
- normalizeEvaluation,
- pseudoNL,
- cpToWinrate,
-} from '../../src/lib/stockfish'
-
-describe('Stockfish utilities', () => {
- describe('normalize', () => {
- it('should normalize values correctly', () => {
- expect(normalize(5, 0, 10)).toBe(0.5)
- expect(normalize(0, 0, 10)).toBe(0)
- expect(normalize(10, 0, 10)).toBe(1)
- expect(normalize(7.5, 0, 10)).toBe(0.75)
- })
-
- it('should handle negative ranges', () => {
- expect(normalize(-5, -10, 0)).toBe(0.5)
- expect(normalize(-10, -10, 0)).toBe(0)
- expect(normalize(0, -10, 0)).toBe(1)
- })
-
- it('should handle equal min and max', () => {
- expect(normalize(5, 5, 5)).toBe(1)
- expect(normalize(0, 0, 0)).toBe(1)
- })
-
- it('should handle values outside range', () => {
- expect(normalize(15, 0, 10)).toBe(1.5)
- expect(normalize(-5, 0, 10)).toBe(-0.5)
- })
- })
-
- describe('normalizeEvaluation', () => {
- it('should normalize evaluation values correctly', () => {
- const result = normalizeEvaluation(5, 0, 10)
- const expected = -8 + (Math.abs(5 - 0) / Math.abs(10 - 0)) * (0 - -8)
- expect(result).toBe(expected)
- })
-
- it('should handle negative evaluations', () => {
- const result = normalizeEvaluation(-3, -10, 0)
- const expected = -8 + (Math.abs(-3 - -10) / Math.abs(0 - -10)) * (0 - -8)
- expect(result).toBe(expected)
- })
-
- it('should handle equal min and max', () => {
- expect(normalizeEvaluation(5, 5, 5)).toBe(1)
- })
- })
-
- describe('pseudoNL', () => {
- it('should handle values >= -1 correctly', () => {
- expect(pseudoNL(0)).toBe(-0.5)
- expect(pseudoNL(1)).toBe(0)
- expect(pseudoNL(-1)).toBe(-1)
- expect(pseudoNL(2)).toBe(0.5)
- })
-
- it('should handle values < -1 correctly', () => {
- expect(pseudoNL(-2)).toBe(-2)
- expect(pseudoNL(-5)).toBe(-5)
- expect(pseudoNL(-1.5)).toBe(-1.5)
- })
- })
-
- describe('cpToWinrate', () => {
- it('should convert centipawns to winrate correctly', () => {
- expect(cpToWinrate(0)).toBeCloseTo(0.526949638981131, 5)
- expect(cpToWinrate(100)).toBeCloseTo(0.6271095095579187, 5)
- expect(cpToWinrate(-100)).toBeCloseTo(0.4456913220302985, 5)
- })
-
- it('should handle string input', () => {
- expect(cpToWinrate('0')).toBeCloseTo(0.526949638981131, 5)
- expect(cpToWinrate('100')).toBeCloseTo(0.6271095095579187, 5)
- expect(cpToWinrate('-100')).toBeCloseTo(0.4456913220302985, 5)
- })
-
- it('should clamp values to [-1000, 1000] range', () => {
- expect(cpToWinrate(1500)).toBeCloseTo(0.8518353443061348, 5) // Should clamp to 1000
- expect(cpToWinrate(-1500)).toBeCloseTo(0.16874792794783955, 5) // Should clamp to -1000
- })
-
- it('should handle edge cases', () => {
- expect(cpToWinrate(1000)).toBeCloseTo(0.8518353443061348, 5)
- expect(cpToWinrate(-1000)).toBeCloseTo(0.16874792794783955, 5)
- })
-
- it('should handle invalid input with allowNaN=false', () => {
- // The function actually returns 0.5 for invalid input when allowNaN=false
- expect(cpToWinrate('invalid')).toBe(0.5)
- })
-
- it('should handle invalid input with allowNaN=true', () => {
- expect(cpToWinrate('invalid', true)).toBeNaN()
- })
-
- it('should handle edge case with no matching key', () => {
- // This should not happen with proper clamping and rounding, but testing edge case
- expect(cpToWinrate(0, true)).toBeCloseTo(0.526949638981131, 5)
- })
- })
-})
diff --git a/__tests__/types/tree-fen-moves.test.ts b/__tests__/types/tree-fen-moves.test.ts
deleted file mode 100644
index 68c3c80d..00000000
--- a/__tests__/types/tree-fen-moves.test.ts
+++ /dev/null
@@ -1,134 +0,0 @@
-import { GameTree, GameNode } from 'src/types/base/tree'
-import { Chess, Move } from 'chess.ts'
-
-describe('GameTree FEN Position Move Handling', () => {
- describe('Making moves from custom FEN position', () => {
- it('should create main line move when making first move from FEN position', () => {
- // Custom FEN position - middle game position
- const customFen =
- 'r1bqkb1r/pppp1ppp/2n2n2/4p3/2B1P3/3P1N2/PPP2PPP/RNBQK2R w KQkq - 4 4'
- const tree = new GameTree(customFen)
- const rootNode = tree.getRoot()
-
- // Verify initial state - should have only root node
- expect(rootNode.fen).toBe(customFen)
- expect(rootNode.mainChild).toBeNull()
- expect(rootNode.children.length).toBe(0)
-
- // Make a move from the position
- const chess = new Chess(customFen)
- const moveResult = chess.move('Ng5') // A valid move from this position
- expect(moveResult).toBeTruthy()
-
- const newFen = chess.fen()
- const moveUci = 'f3g5'
- const san = 'Ng5'
-
- // The first move should create a main line move, not a variation
- const newNode = tree.addMainMove(rootNode, newFen, moveUci, san)
-
- // Verify the move was added as main line
- expect(rootNode.mainChild).toBe(newNode)
- expect(newNode.isMainline).toBe(true)
- expect(newNode.move).toBe(moveUci)
- expect(newNode.san).toBe(san)
- expect(newNode.fen).toBe(newFen)
-
- // Verify main line structure
- const mainLine = tree.getMainLine()
- expect(mainLine.length).toBe(2) // root + one move
- expect(mainLine[0]).toBe(rootNode)
- expect(mainLine[1]).toBe(newNode)
- })
-
- it('should create variations when making alternative moves from FEN position', () => {
- const customFen =
- 'r1bqkb1r/pppp1ppp/2n2n2/4p3/2B1P3/3P1N2/PPP2PPP/RNBQK2R w KQkq - 4 4'
- const tree = new GameTree(customFen)
- const rootNode = tree.getRoot()
-
- // First move - should be main line
- const chess1 = new Chess(customFen)
- const move1 = chess1.move('Ng5') as Move
- expect(move1).toBeTruthy()
- const mainNode = tree.addMainMove(
- rootNode,
- chess1.fen(),
- 'f3g5',
- move1.san,
- )
-
- // Second alternative move from same position - should be variation
- const chess2 = new Chess(customFen)
- const move2 = chess2.move('Nxe5') as Move
- expect(move2).toBeTruthy()
- const variationNode = tree.addVariation(
- rootNode,
- chess2.fen(),
- 'f3e5',
- move2.san,
- )
-
- // Verify structure
- expect(rootNode.mainChild).toBe(mainNode)
- expect(rootNode.children.length).toBe(2)
- expect(rootNode.getVariations()).toContain(variationNode)
- expect(variationNode.isMainline).toBe(false)
-
- // Main line should still be just root + main move
- const mainLine = tree.getMainLine()
- expect(mainLine.length).toBe(2)
- expect(mainLine[1]).toBe(mainNode)
- })
-
- it('should handle multiple moves extending main line from FEN', () => {
- const customFen =
- 'r1bqkb1r/pppp1ppp/2n2n2/4p3/2B1P3/3P1N2/PPP2PPP/RNBQK2R w KQkq - 4 4'
- const tree = new GameTree(customFen)
- const rootNode = tree.getRoot()
-
- // Add first main line move
- const chess1 = new Chess(customFen)
- const move1 = chess1.move('Ng5') as Move
- expect(move1).toBeTruthy()
- const node1 = tree.addMainMove(rootNode, chess1.fen(), 'f3g5', move1.san)
-
- // Add second main line move
- const move2 = chess1.move('d6') as Move
- expect(move2).toBeTruthy()
- const node2 = tree.addMainMove(node1, chess1.fen(), 'd7d6', move2.san)
-
- // Verify main line structure
- const mainLine = tree.getMainLine()
- expect(mainLine.length).toBe(3) // root + two moves
- expect(mainLine[0]).toBe(rootNode)
- expect(mainLine[1]).toBe(node1)
- expect(mainLine[2]).toBe(node2)
-
- // Verify parent-child relationships
- expect(rootNode.mainChild).toBe(node1)
- expect(node1.mainChild).toBe(node2)
- expect(node2.mainChild).toBeNull()
- })
- })
-
- describe('FEN position detection', () => {
- it('should properly detect custom FEN vs starting position', () => {
- const startingFen =
- 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'
- const customFen =
- 'r1bqkb1r/pppp1ppp/2n2n2/4p3/2B1P3/3P1N2/PPP2PPP/RNBQK2R w KQkq - 4 4'
-
- const startingTree = new GameTree(startingFen)
- const customTree = new GameTree(customFen)
-
- // Starting position should not have FEN header
- expect(startingTree.getHeader('FEN')).toBeUndefined()
- expect(startingTree.getHeader('SetUp')).toBeUndefined()
-
- // Custom FEN should have headers
- expect(customTree.getHeader('FEN')).toBe(customFen)
- expect(customTree.getHeader('SetUp')).toBe('1')
- })
- })
-})
diff --git a/__tests__/types/tree.test.ts b/__tests__/types/tree.test.ts
deleted file mode 100644
index 2e03552b..00000000
--- a/__tests__/types/tree.test.ts
+++ /dev/null
@@ -1,177 +0,0 @@
-import { GameNode } from 'src/types/base/tree'
-import { StockfishEvaluation, MaiaEvaluation } from 'src/types'
-
-describe('GameNode Move Classification', () => {
- describe('Excellent Move Criteria', () => {
- it('should classify move as excellent when Maia probability < 10% and winrate is 10% higher than weighted average', () => {
- const parentNode = new GameNode(
- 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
- )
-
- // Mock Stockfish evaluation with winrate vectors
- const stockfishEval: StockfishEvaluation = {
- sent: true,
- depth: 15,
- model_move: 'e2e4',
- model_optimal_cp: 50,
- cp_vec: { e2e4: 50, d2d4: 40, g1f3: 30 },
- cp_relative_vec: { e2e4: 0, d2d4: -10, g1f3: -20 },
- winrate_vec: { e2e4: 0.6, d2d4: 0.58, g1f3: 0.4 },
- winrate_loss_vec: { e2e4: 0, d2d4: -0.02, g1f3: -0.2 },
- }
-
- // Mock Maia evaluation with policy probabilities
- const maiaEval: { [rating: string]: MaiaEvaluation } = {
- maia_kdd_1500: {
- policy: {
- e2e4: 0.5, // 50% probability - most likely move
- d2d4: 0.3, // 30% probability
- g1f3: 0.05, // 5% probability - less than 10% threshold
- },
- value: 0.6,
- },
- }
-
- // Add analysis to parent node
- parentNode.addStockfishAnalysis(stockfishEval, 'maia_kdd_1500')
- parentNode.addMaiaAnalysis(maiaEval, 'maia_kdd_1500')
-
- // Calculate weighted average manually for verification:
- // weighted_avg = (0.5 * 0.6 + 0.3 * 0.58 + 0.05 * 0.4) / (0.5 + 0.3 + 0.05)
- // weighted_avg = (0.3 + 0.174 + 0.02) / 0.85 = 0.494 / 0.85 ≈ 0.581
- // g1f3 winrate (0.4) is NOT 10% higher than weighted average (0.581)
- // So g1f3 should NOT be excellent despite low Maia probability
-
- // Test move with low Maia probability but not high enough winrate
- const classificationG1f3 = GameNode.classifyMove(
- parentNode,
- 'g1f3',
- 'maia_kdd_1500',
- )
- expect(classificationG1f3.excellent).toBe(false)
-
- // Now test with a different scenario where a move has both low probability and high winrate
- const stockfishEval2: StockfishEvaluation = {
- sent: true,
- depth: 15,
- model_move: 'e2e4',
- model_optimal_cp: 50,
- cp_vec: { e2e4: 50, d2d4: 40, b1c3: 45 },
- cp_relative_vec: { e2e4: 0, d2d4: -10, b1c3: -5 },
- winrate_vec: { e2e4: 0.6, d2d4: 0.45, b1c3: 0.7 },
- winrate_loss_vec: { e2e4: 0, d2d4: -0.15, b1c3: 0.1 },
- }
-
- const maiaEval2: { [rating: string]: MaiaEvaluation } = {
- maia_kdd_1500: {
- policy: {
- e2e4: 0.6, // 60% probability
- d2d4: 0.35, // 35% probability
- b1c3: 0.05, // 5% probability - less than 10% threshold
- },
- value: 0.6,
- },
- }
-
- const parentNode2 = new GameNode(
- 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
- )
- parentNode2.addStockfishAnalysis(stockfishEval2, 'maia_kdd_1500')
- parentNode2.addMaiaAnalysis(maiaEval2, 'maia_kdd_1500')
-
- // Calculate weighted average: (0.6 * 0.6 + 0.35 * 0.45 + 0.05 * 0.7) / 1.0
- // = (0.36 + 0.1575 + 0.035) / 1.0 = 0.5525
- // b1c3 winrate (0.7) is about 14.75% higher than weighted average (0.5525)
- // So b1c3 should be excellent (low Maia probability AND high relative winrate)
-
- const classificationB1c3 = GameNode.classifyMove(
- parentNode2,
- 'b1c3',
- 'maia_kdd_1500',
- )
- expect(classificationB1c3.excellent).toBe(true)
- })
-
- it('should not classify move as excellent when Maia probability >= 10%', () => {
- const parentNode = new GameNode(
- 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
- )
-
- const stockfishEval: StockfishEvaluation = {
- sent: true,
- depth: 15,
- model_move: 'e2e4',
- model_optimal_cp: 50,
- cp_vec: { e2e4: 50, d2d4: 40 },
- cp_relative_vec: { e2e4: 0, d2d4: -10 },
- winrate_vec: { e2e4: 0.6, d2d4: 0.7 },
- winrate_loss_vec: { e2e4: 0, d2d4: 0.1 },
- }
-
- const maiaEval: { [rating: string]: MaiaEvaluation } = {
- maia_kdd_1500: {
- policy: {
- e2e4: 0.8, // 80% probability - above 10% threshold
- d2d4: 0.2, // 20% probability - above 10% threshold
- },
- value: 0.6,
- },
- }
-
- parentNode.addStockfishAnalysis(stockfishEval, 'maia_kdd_1500')
- parentNode.addMaiaAnalysis(maiaEval, 'maia_kdd_1500')
-
- // Even though d2d4 has higher winrate than weighted average,
- // it should not be excellent because Maia probability > 10%
- const classification = GameNode.classifyMove(
- parentNode,
- 'd2d4',
- 'maia_kdd_1500',
- )
- expect(classification.excellent).toBe(false)
- })
-
- it('should not classify move as excellent when winrate advantage < 10%', () => {
- const parentNode = new GameNode(
- 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
- )
-
- const stockfishEval: StockfishEvaluation = {
- sent: true,
- depth: 15,
- model_move: 'e2e4',
- model_optimal_cp: 50,
- cp_vec: { e2e4: 50, d2d4: 40, a2a3: 20 },
- cp_relative_vec: { e2e4: 0, d2d4: -10, a2a3: -30 },
- winrate_vec: { e2e4: 0.6, d2d4: 0.55, a2a3: 0.62 },
- winrate_loss_vec: { e2e4: 0, d2d4: -0.05, a2a3: 0.02 },
- }
-
- const maiaEval: { [rating: string]: MaiaEvaluation } = {
- maia_kdd_1500: {
- policy: {
- e2e4: 0.7, // 70% probability
- d2d4: 0.25, // 25% probability
- a2a3: 0.05, // 5% probability - below 10% threshold
- },
- value: 0.6,
- },
- }
-
- parentNode.addStockfishAnalysis(stockfishEval, 'maia_kdd_1500')
- parentNode.addMaiaAnalysis(maiaEval, 'maia_kdd_1500')
-
- // Weighted average: (0.7 * 0.6 + 0.25 * 0.55 + 0.05 * 0.62) / 1.0
- // = (0.42 + 0.1375 + 0.031) / 1.0 = 0.5885
- // a2a3 winrate (0.62) is only about 3.15% higher than weighted average
- // So a2a3 should NOT be excellent (advantage < 10%)
-
- const classification = GameNode.classifyMove(
- parentNode,
- 'a2a3',
- 'maia_kdd_1500',
- )
- expect(classification.excellent).toBe(false)
- })
- })
-})
diff --git a/src/api/analysis.ts b/src/api/analysis.ts
new file mode 100644
index 00000000..e5c45c84
--- /dev/null
+++ b/src/api/analysis.ts
@@ -0,0 +1,370 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import {
+ Player,
+ AnalyzedGame,
+ MoveValueMapping,
+ CachedEngineAnalysisEntry,
+ WorldChampionshipGameListEntry,
+ RawMove,
+} from 'src/types'
+import {
+ readLichessStream,
+ buildGameTreeFromMoveList,
+ buildMovesListFromGameStates,
+ insertBackendStockfishEvalToGameTree,
+} from 'src/lib'
+import { buildUrl } from './utils'
+import { AvailableMoves } from 'src/types/puzzle'
+import { Chess } from 'chess.ts'
+
+export const fetchWorldChampionshipGameList = async (): Promise<
+ Map
+> => {
+ const res = await fetch(buildUrl('analysis/list'))
+ const data = await res.json()
+
+ return data
+}
+
+export const fetchMaiaGameList = async (
+ type = 'play',
+ page = 1,
+ lichessId?: string,
+) => {
+ const url = buildUrl(`analysis/user/list/${type}/${page}`)
+ const searchParams = new URLSearchParams()
+
+ if (lichessId) {
+ searchParams.append('lichess_id', lichessId)
+ }
+
+ const fullUrl = searchParams.toString()
+ ? `${url}?${searchParams.toString()}`
+ : url
+ const res = await fetch(fullUrl)
+
+ const data = await res.json()
+
+ return data
+}
+
+export const streamLichessGames = async (
+ username: string,
+ onMessage: (data: any) => void,
+) => {
+ const stream = fetch(
+ `https://lichess.org/api/games/user/${username}?max=100&pgnInJson=true`,
+ {
+ headers: {
+ Accept: 'application/x-ndjson',
+ },
+ },
+ )
+ stream.then(readLichessStream(onMessage))
+}
+
+export const fetchPgnOfLichessGame = async (id: string): Promise => {
+ const res = await fetch(`https://lichess.org/game/export/${id}`, {
+ headers: {
+ Accept: 'application/x-chess-pgn',
+ },
+ })
+ return res.text()
+}
+
+export const fetchAnalyzedWorldChampionshipGame = async (
+ gameId = ['FkgYSri1'],
+) => {
+ const res = await fetch(
+ buildUrl(`analysis/analysis_list/${gameId.join('/')}`),
+ )
+
+ const data = await res.json()
+
+ const id = data['id']
+ const termination = {
+ ...data['termination'],
+ condition: 'Normal',
+ }
+
+ const gameType = 'blitz'
+ const blackPlayer = data['black_player'] as Player
+ const whitePlayer = data['white_player'] as Player
+
+ const maiaEvals: {
+ [model: string]: MoveValueMapping[]
+ } = {}
+ const stockfishEvaluations: MoveValueMapping[] = data['stockfish_evals']
+
+ const availableMoves: AvailableMoves[] = []
+
+ for (const model of data['maia_versions']) {
+ maiaEvals[model] = data['maia_evals'][model]
+ }
+
+ for (const position of data['move_maps']) {
+ const moves: AvailableMoves = {}
+ for (const move of position) {
+ const fromTo = move.move.join('')
+ const san = move['move_san']
+ const { check, fen } = move
+
+ moves[fromTo] = {
+ board: fen,
+ check,
+ san,
+ lastMove: move.move,
+ } as RawMove
+ }
+ availableMoves.push(moves)
+ }
+
+ const gameStates = data['game_states']
+
+ const moves = buildMovesListFromGameStates(gameStates)
+ const tree = buildGameTreeFromMoveList(moves, moves[0].board)
+ insertBackendStockfishEvalToGameTree(tree, moves, stockfishEvaluations)
+
+ return {
+ id,
+ blackPlayer,
+ whitePlayer,
+ availableMoves,
+ gameType,
+ termination,
+ tree,
+ } as AnalyzedGame
+}
+
+export const fetchAnalyzedPgnGame = async (id: string, pgn: string) => {
+ const res = await fetch(buildUrl('analysis/analyze_user_game'), {
+ method: 'POST',
+ body: pgn,
+ headers: {
+ 'Content-Type': 'text/plain',
+ },
+ })
+
+ const data = await res.json()
+
+ const termination = {
+ ...data['termination'],
+ condition: 'Normal',
+ }
+
+ const gameType = 'blitz'
+ const blackPlayer = data['black_player'] as Player
+ const whitePlayer = data['white_player'] as Player
+
+ const maiaEvals: { [model: string]: MoveValueMapping[] } = {}
+ const availableMoves: AvailableMoves[] = []
+
+ for (const model of data['maia_versions']) {
+ maiaEvals[model] = data['maia_evals'][model]
+ }
+
+ for (const position of data['move_maps']) {
+ const moves: AvailableMoves = {}
+ for (const move of position) {
+ const fromTo = move.move.join('')
+ const san = move['move_san']
+ const { check, fen } = move
+
+ moves[fromTo] = {
+ board: fen,
+ check,
+ san,
+ lastMove: move.move,
+ } as RawMove
+ }
+ availableMoves.push(moves)
+ }
+
+ const gameStates = data['game_states']
+
+ const moves = buildMovesListFromGameStates(gameStates)
+ const tree = buildGameTreeFromMoveList(moves, moves[0].board)
+
+ return {
+ id,
+ blackPlayer,
+ whitePlayer,
+ availableMoves,
+ gameType,
+ termination,
+ tree,
+ } as AnalyzedGame
+}
+
+export const fetchAnalyzedMaiaGame = async (
+ id: string,
+ game_type: 'play' | 'hand' | 'brain' | 'custom',
+) => {
+ const res = await fetch(
+ buildUrl(
+ `analysis/user/analyze_user_maia_game/${id}?` +
+ new URLSearchParams({
+ game_type,
+ }),
+ ),
+ {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'text/plain',
+ },
+ },
+ )
+
+ const data = await res.json()
+
+ const termination = {
+ ...data['termination'],
+ condition: 'Normal',
+ }
+
+ const gameType = 'blitz'
+ const blackPlayer = data['black_player'] as Player
+ const whitePlayer = data['white_player'] as Player
+
+ const maiaPattern = /maia_kdd_1\d00/
+
+ if (blackPlayer.name && maiaPattern.test(blackPlayer.name)) {
+ blackPlayer.name = blackPlayer.name.replace('maia_kdd_', 'Maia ')
+ }
+
+ if (whitePlayer.name && maiaPattern.test(whitePlayer.name)) {
+ whitePlayer.name = whitePlayer.name.replace('maia_kdd_', 'Maia ')
+ }
+
+ const maiaEvals: { [model: string]: MoveValueMapping[] } = {}
+ const availableMoves: AvailableMoves[] = []
+
+ for (const model of data['maia_versions']) {
+ maiaEvals[model] = data['maia_evals'][model]
+ }
+
+ for (const position of data['move_maps']) {
+ const moves: AvailableMoves = {}
+ for (const move of position) {
+ const fromTo = move.move.join('')
+ const san = move['move_san']
+ const { check, fen } = move
+
+ moves[fromTo] = {
+ board: fen,
+ check,
+ san,
+ lastMove: move.move,
+ } as RawMove
+ }
+ availableMoves.push(moves)
+ }
+
+ const gameStates = data['game_states']
+
+ const moves = buildMovesListFromGameStates(gameStates)
+ const tree = buildGameTreeFromMoveList(
+ moves,
+ moves.length ? moves[0].board : new Chess().fen(),
+ )
+
+ return {
+ id,
+ type: game_type,
+ blackPlayer,
+ whitePlayer,
+ moves,
+ availableMoves,
+ gameType,
+ termination,
+ tree,
+ } as AnalyzedGame
+}
+
+export const storeGameAnalysisCache = async (
+ gameId: string,
+ analysisData: CachedEngineAnalysisEntry[],
+): Promise => {
+ const res = await fetch(
+ buildUrl(`analysis/store_engine_analysis/${gameId}`),
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(analysisData),
+ },
+ )
+
+ if (!res.ok) {
+ console.error('Failed to cache engine analysis')
+ }
+}
+
+export const retrieveGameAnalysisCache = async (
+ gameId: string,
+): Promise<{ positions: CachedEngineAnalysisEntry[] } | null> => {
+ const res = await fetch(buildUrl(`analysis/get_engine_analysis/${gameId}`))
+
+ if (res.status === 404) {
+ return null
+ }
+
+ if (!res.ok) {
+ console.error('Failed to retrieve engine analysis')
+ }
+
+ const data = await res.json()
+
+ return data
+}
+
+export const updateGameMetadata = async (
+ gameType: 'custom' | 'play' | 'hand' | 'brain',
+ gameId: string,
+ metadata: {
+ custom_name?: string
+ is_favorited?: boolean
+ },
+): Promise => {
+ const res = await fetch(
+ buildUrl(`analysis/update_metadata/${gameType}/${gameId}`),
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(metadata),
+ },
+ )
+
+ if (!res.ok) {
+ console.error('Failed to update game metadata')
+ }
+}
+
+export const storeCustomGame = async (data: {
+ name?: string
+ pgn?: string
+ fen?: string
+}): Promise<{
+ id: string
+ name: string
+ pgn?: string
+ fen?: string
+ created_at: string
+}> => {
+ const res = await fetch(buildUrl('analysis/store_custom_game'), {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(data),
+ })
+
+ if (!res.ok) {
+ console.error(`Failed to store custom game: ${await res.text()}`)
+ }
+
+ return res.json()
+}
diff --git a/src/api/analysis/analysis.ts b/src/api/analysis/analysis.ts
deleted file mode 100644
index d7c00de3..00000000
--- a/src/api/analysis/analysis.ts
+++ /dev/null
@@ -1,784 +0,0 @@
-/* eslint-disable @typescript-eslint/no-explicit-any */
-import {
- Player,
- MoveMap,
- GameTree,
- GameNode,
- AnalyzedGame,
- MaiaEvaluation,
- PositionEvaluation,
- StockfishEvaluation,
- AnalysisTournamentGame,
-} from 'src/types'
-import { buildUrl } from '../utils'
-import { cpToWinrate } from 'src/lib/stockfish'
-import { AvailableMoves } from 'src/types/training'
-import { Chess } from 'chess.ts'
-import {
- saveCustomAnalysis,
- getCustomAnalysisById,
-} from 'src/lib/customAnalysis'
-
-function buildGameTree(moves: any[], initialFen: string) {
- const tree = new GameTree(initialFen)
- let currentNode = tree.getRoot()
-
- for (let i = 0; i < moves.length; i++) {
- const move = moves[i]
-
- if (move.lastMove) {
- const [from, to] = move.lastMove
- currentNode = tree.addMainMove(
- currentNode,
- move.board,
- from + to,
- move.san || '',
- )
- }
- }
-
- return tree
-}
-
-const readStream = (processLine: (data: any) => void) => (response: any) => {
- const stream = response.body.getReader()
- const matcher = /\r?\n/
- const decoder = new TextDecoder()
- let buf = ''
-
- const loop = () =>
- stream.read().then(({ done, value }: { done: boolean; value: any }) => {
- if (done) {
- if (buf.length > 0) processLine(JSON.parse(buf))
- } else {
- const chunk = decoder.decode(value, {
- stream: true,
- })
- buf += chunk
-
- const parts = (buf || '').split(matcher)
- buf = parts.pop() as string
- for (const i of parts.filter((p) => p)) processLine(JSON.parse(i))
-
- return loop()
- }
- })
-
- return loop()
-}
-
-export const getAnalysisList = async (): Promise<
- Map
-> => {
- const res = await fetch(buildUrl('analysis/list'))
-
- if (res.status === 401) {
- throw new Error('Unauthorized')
- }
-
- const data = await res.json()
-
- return data
-}
-
-export const getAnalysisGameList = async (
- type = 'play',
- page = 1,
- lichessId?: string,
-) => {
- const url = buildUrl(`analysis/user/list/${type}/${page}`)
- const searchParams = new URLSearchParams()
-
- if (lichessId) {
- searchParams.append('lichess_id', lichessId)
- }
-
- const fullUrl = searchParams.toString()
- ? `${url}?${searchParams.toString()}`
- : url
- const res = await fetch(fullUrl)
-
- if (res.status === 401) {
- throw new Error('Unauthorized')
- }
-
- const data = await res.json()
-
- return data
-}
-
-export const getLichessGames = async (
- username: string,
- onMessage: (data: any) => void,
-) => {
- const stream = fetch(
- `https://lichess.org/api/games/user/${username}?max=100&pgnInJson=true`,
- {
- headers: {
- Accept: 'application/x-ndjson',
- },
- },
- )
- stream.then(readStream(onMessage))
-}
-
-export const getLichessGamePGN = async (id: string) => {
- const res = await fetch(`https://lichess.org/game/export/${id}`, {
- headers: {
- Accept: 'application/x-chess-pgn',
- },
- })
- return res.text()
-}
-
-function convertMoveMapToStockfishEval(
- moveMap: MoveMap,
- turn: 'w' | 'b',
-): StockfishEvaluation {
- const cp_vec: { [key: string]: number } = {}
- const cp_relative_vec: { [key: string]: number } = {}
- let model_optimal_cp = -Infinity
- let model_move = ''
-
- for (const move in moveMap) {
- const cp = moveMap[move]
- cp_vec[move] = cp
- if (cp > model_optimal_cp) {
- model_optimal_cp = cp
- model_move = move
- }
- }
-
- for (const move in cp_vec) {
- const cp = moveMap[move]
- cp_relative_vec[move] = cp - model_optimal_cp
- }
-
- const cp_vec_sorted = Object.fromEntries(
- Object.entries(cp_vec).sort(([, a], [, b]) => b - a),
- )
-
- const cp_relative_vec_sorted = Object.fromEntries(
- Object.entries(cp_relative_vec).sort(([, a], [, b]) => b - a),
- )
-
- const winrate_vec: { [key: string]: number } = {}
- let max_winrate = -Infinity
-
- for (const move in cp_vec_sorted) {
- const cp = cp_vec_sorted[move]
- const winrate = cpToWinrate(cp, false)
- winrate_vec[move] = winrate
-
- if (winrate_vec[move] > max_winrate) {
- max_winrate = winrate_vec[move]
- }
- }
-
- const winrate_loss_vec: { [key: string]: number } = {}
- for (const move in winrate_vec) {
- winrate_loss_vec[move] = winrate_vec[move] - max_winrate
- }
-
- const winrate_vec_sorted = Object.fromEntries(
- Object.entries(winrate_vec).sort(([, a], [, b]) => b - a),
- )
-
- const winrate_loss_vec_sorted = Object.fromEntries(
- Object.entries(winrate_loss_vec).sort(([, a], [, b]) => b - a),
- )
-
- if (turn === 'b') {
- model_optimal_cp *= -1
- for (const move in cp_vec_sorted) {
- cp_vec_sorted[move] *= -1
- }
- }
-
- return {
- sent: true,
- depth: 20,
- model_move: model_move,
- model_optimal_cp: model_optimal_cp,
- cp_vec: cp_vec_sorted,
- cp_relative_vec: cp_relative_vec_sorted,
- winrate_vec: winrate_vec_sorted,
- winrate_loss_vec: winrate_loss_vec_sorted,
- }
-}
-
-export const getAnalyzedTournamentGame = async (gameId = ['FkgYSri1']) => {
- const res = await fetch(
- buildUrl(`analysis/analysis_list/${gameId.join('/')}`),
- )
-
- if (res.status === 401) {
- throw new Error('Unauthorized')
- }
-
- const data = await res.json()
- const id = data['id']
- const termination = {
- ...data['termination'],
- condition: 'Normal',
- }
-
- const gameType = 'blitz'
- const blackPlayer = data['black_player'] as Player
- const whitePlayer = data['white_player'] as Player
-
- const maiaEvals: { [model: string]: MoveMap[] } = {}
- const stockfishEvaluations: MoveMap[] = data['stockfish_evals']
-
- const availableMoves: AvailableMoves[] = []
-
- for (const model of data['maia_versions']) {
- maiaEvals[model] = data['maia_evals'][model]
- }
-
- for (const position of data['move_maps']) {
- const moves: AvailableMoves = {}
- for (const move of position) {
- const fromTo = move.move.join('')
- const san = move['move_san']
- const { check, fen } = move
-
- moves[fromTo] = {
- board: fen,
- check,
- san,
- lastMove: move.move,
- }
- }
- availableMoves.push(moves)
- }
-
- const gameStates = data['game_states']
-
- const moves = gameStates.map((gameState: any) => {
- const {
- last_move: lastMove,
- fen,
- check,
- last_move_san: san,
- evaluations: maia_values,
- } = gameState
-
- return {
- board: fen,
- lastMove,
- san,
- check,
- maia_values,
- }
- })
-
- const maiaEvaluations = [] as { [rating: number]: MaiaEvaluation }[]
-
- const tree = buildGameTree(moves, moves[0].board)
-
- let currentNode: GameNode | null = tree.getRoot()
-
- for (let i = 0; i < moves.length; i++) {
- if (!currentNode) {
- break
- }
-
- const stockfishEval = stockfishEvaluations[i]
- ? convertMoveMapToStockfishEval(
- stockfishEvaluations[i],
- moves[i].board.split(' ')[1],
- )
- : undefined
-
- if (stockfishEval) {
- currentNode.addStockfishAnalysis(stockfishEval)
- }
- currentNode = currentNode?.mainChild
- }
-
- return {
- id,
- blackPlayer,
- whitePlayer,
- moves,
- maiaEvaluations,
- stockfishEvaluations,
- availableMoves,
- gameType,
- termination,
- tree,
- } as any as AnalyzedGame
-}
-
-export const getAnalyzedLichessGame = async (id: string, pgn: string) => {
- const res = await fetch(buildUrl('analysis/analyze_user_game'), {
- method: 'POST',
- body: pgn,
- headers: {
- 'Content-Type': 'text/plain',
- },
- })
-
- if (res.status === 401) {
- throw new Error('Unauthorized')
- }
-
- const data = await res.json()
-
- const termination = {
- ...data['termination'],
- condition: 'Normal',
- }
-
- const gameType = 'blitz'
- const blackPlayer = data['black_player'] as Player
- const whitePlayer = data['white_player'] as Player
-
- const maiaEvals: { [model: string]: MoveMap[] } = {}
- const positionEvaluations: { [model: string]: PositionEvaluation[] } = {}
- const availableMoves: AvailableMoves[] = []
-
- for (const model of data['maia_versions']) {
- maiaEvals[model] = data['maia_evals'][model]
- positionEvaluations[model] = Object.keys(data['maia_evals'][model]).map(
- () => ({
- trickiness: 1,
- performance: 1,
- }),
- )
- }
-
- for (const position of data['move_maps']) {
- const moves: AvailableMoves = {}
- for (const move of position) {
- const fromTo = move.move.join('')
- const san = move['move_san']
- const { check, fen } = move
-
- moves[fromTo] = {
- board: fen,
- check,
- san,
- lastMove: move.move,
- }
- }
- availableMoves.push(moves)
- }
-
- const gameStates = data['game_states']
-
- const moves = gameStates.map((gameState: any) => {
- const {
- last_move: lastMove,
- fen,
- check,
- last_move_san: san,
- evaluations: maia_values,
- } = gameState
-
- return {
- board: fen,
- lastMove,
- san,
- check,
- maia_values,
- }
- })
-
- const maiaEvaluations = [] as { [rating: number]: MaiaEvaluation }[]
- const stockfishEvaluations: StockfishEvaluation[] = []
- const tree = buildGameTree(moves, moves[0].board)
-
- return {
- id,
- blackPlayer,
- whitePlayer,
- moves,
- availableMoves,
- gameType,
- termination,
- maiaEvaluations,
- stockfishEvaluations,
- tree,
- type: 'brain',
- pgn,
- } as AnalyzedGame
-}
-
-const createAnalyzedGameFromPGN = async (
- pgn: string,
- id?: string,
-): Promise => {
- const chess = new Chess()
-
- try {
- chess.loadPgn(pgn)
- } catch (error) {
- throw new Error('Invalid PGN format')
- }
-
- const history = chess.history({ verbose: true })
- const headers = chess.header()
-
- const moves = []
- const tempChess = new Chess()
-
- const startingFen =
- headers.FEN || 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'
- if (headers.FEN) {
- tempChess.load(headers.FEN)
- }
-
- moves.push({
- board: tempChess.fen(),
- lastMove: undefined,
- san: undefined,
- check: tempChess.inCheck(),
- maia_values: {},
- })
-
- for (const move of history) {
- tempChess.move(move)
- moves.push({
- board: tempChess.fen(),
- lastMove: [move.from, move.to] as [string, string],
- san: move.san,
- check: tempChess.inCheck(),
- maia_values: {},
- })
- }
-
- const tree = buildGameTree(moves, startingFen)
-
- return {
- id: id || `pgn-${Date.now()}`,
- blackPlayer: { name: headers.Black || 'Black', rating: undefined },
- whitePlayer: { name: headers.White || 'White', rating: undefined },
- moves,
- availableMoves: new Array(moves.length).fill({}),
- gameType: 'custom',
- termination: {
- result: headers.Result || '*',
- winner:
- headers.Result === '1-0'
- ? 'white'
- : headers.Result === '0-1'
- ? 'black'
- : 'none',
- condition: 'Normal',
- },
- maiaEvaluations: new Array(moves.length).fill({}),
- stockfishEvaluations: new Array(moves.length).fill(undefined),
- tree,
- type: 'custom-pgn' as const,
- pgn,
- } as AnalyzedGame
-}
-
-export const getAnalyzedCustomPGN = async (
- pgn: string,
- name?: string,
-): Promise => {
- const stored = await saveCustomAnalysis('pgn', pgn, name)
-
- return createAnalyzedGameFromPGN(pgn, stored.id)
-}
-
-const createAnalyzedGameFromFEN = async (
- fen: string,
- id?: string,
-): Promise => {
- const chess = new Chess()
-
- try {
- chess.load(fen)
- } catch (error) {
- throw new Error('Invalid FEN format')
- }
-
- const moves = [
- {
- board: fen,
- lastMove: undefined,
- san: undefined,
- check: chess.inCheck(),
- maia_values: {},
- },
- ]
-
- const tree = new GameTree(fen)
-
- return {
- id: id || `fen-${Date.now()}`,
- blackPlayer: { name: 'Black', rating: undefined },
- whitePlayer: { name: 'White', rating: undefined },
- moves,
- availableMoves: [{}],
- gameType: 'custom',
- termination: {
- result: '*',
- winner: 'none',
- condition: 'Normal',
- },
- maiaEvaluations: [{}],
- stockfishEvaluations: [undefined],
- tree,
- type: 'custom-fen' as const,
- } as AnalyzedGame
-}
-
-export const getAnalyzedCustomFEN = async (
- fen: string,
- name?: string,
-): Promise => {
- const stored = await saveCustomAnalysis('fen', fen, name)
-
- return createAnalyzedGameFromFEN(fen, stored.id)
-}
-
-export const getAnalyzedCustomGame = async (
- id: string,
-): Promise => {
- const stored = getCustomAnalysisById(id)
- if (!stored) {
- throw new Error('Custom analysis not found')
- }
-
- if (stored.type === 'custom-pgn') {
- return createAnalyzedGameFromPGN(stored.data, stored.id)
- } else {
- return createAnalyzedGameFromFEN(stored.data, stored.id)
- }
-}
-
-export const getAnalyzedUserGame = async (
- id: string,
- game_type: 'play' | 'hand' | 'brain',
-) => {
- const res = await fetch(
- buildUrl(
- `analysis/user/analyze_user_maia_game/${id}?` +
- new URLSearchParams({
- game_type,
- }),
- ),
- {
- method: 'GET',
- headers: {
- 'Content-Type': 'text/plain',
- },
- },
- )
-
- if (res.status === 401) {
- throw new Error('Unauthorized')
- }
-
- const data = await res.json()
-
- const termination = {
- ...data['termination'],
- condition: 'Normal',
- }
-
- const gameType = 'blitz'
- const blackPlayer = data['black_player'] as Player
- const whitePlayer = data['white_player'] as Player
-
- const maiaPattern = /maia_kdd_1\d00/
-
- if (blackPlayer.name && maiaPattern.test(blackPlayer.name)) {
- blackPlayer.name = blackPlayer.name.replace('maia_kdd_', 'Maia ')
- }
-
- if (whitePlayer.name && maiaPattern.test(whitePlayer.name)) {
- whitePlayer.name = whitePlayer.name.replace('maia_kdd_', 'Maia ')
- }
-
- const maiaEvals: { [model: string]: MoveMap[] } = {}
-
- const availableMoves: AvailableMoves[] = []
-
- for (const model of data['maia_versions']) {
- maiaEvals[model] = data['maia_evals'][model]
- }
-
- for (const position of data['move_maps']) {
- const moves: AvailableMoves = {}
- for (const move of position) {
- const fromTo = move.move.join('')
- const san = move['move_san']
- const { check, fen } = move
-
- moves[fromTo] = {
- board: fen,
- check,
- san,
- lastMove: move.move,
- }
- }
- availableMoves.push(moves)
- }
-
- const gameStates = data['game_states']
-
- const moves = gameStates.map((gameState: any) => {
- const {
- last_move: lastMove,
- fen,
- check,
- last_move_san: san,
- evaluations: maia_values,
- } = gameState
-
- return {
- board: fen,
- lastMove,
- san,
- check,
- maia_values,
- }
- })
-
- const maiaEvaluations = [] as { [rating: number]: MaiaEvaluation }[]
- const stockfishEvaluations: StockfishEvaluation[] = []
- const tree = buildGameTree(moves, moves[0].board)
-
- return {
- id,
- blackPlayer,
- whitePlayer,
- moves,
- availableMoves,
- gameType,
- termination,
- maiaEvaluations,
- stockfishEvaluations,
- tree,
- type: 'brain',
- } as AnalyzedGame
-}
-
-export interface EngineAnalysisPosition {
- ply: number
- fen: string
- maia?: { [rating: string]: MaiaEvaluation }
- stockfish?: {
- depth: number
- cp_vec: { [move: string]: number }
- }
-}
-
-export const storeEngineAnalysis = async (
- gameId: string,
- analysisData: EngineAnalysisPosition[],
-): Promise => {
- const res = await fetch(
- buildUrl(`analysis/store_engine_analysis/${gameId}`),
- {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify(analysisData),
- },
- )
-
- if (res.status === 401) {
- throw new Error('Unauthorized')
- }
-
- if (!res.ok) {
- throw new Error('Failed to store engine analysis')
- }
-}
-
-// Retrieve stored engine analysis from backend
-export const getEngineAnalysis = async (
- gameId: string,
-): Promise<{ positions: EngineAnalysisPosition[] } | null> => {
- const res = await fetch(buildUrl(`analysis/get_engine_analysis/${gameId}`))
-
- if (res.status === 401) {
- throw new Error('Unauthorized')
- }
-
- if (res.status === 404) {
- // No stored analysis found
- return null
- }
-
- if (!res.ok) {
- throw new Error('Failed to retrieve engine analysis')
- }
-
- return res.json()
-}
-
-export interface UpdateGameMetadataRequest {
- custom_name?: string
- is_favorited?: boolean
-}
-
-export const updateGameMetadata = async (
- gameType: 'custom' | 'play' | 'hand' | 'brain',
- gameId: string,
- metadata: UpdateGameMetadataRequest,
-): Promise => {
- const res = await fetch(
- buildUrl(`analysis/update_metadata/${gameType}/${gameId}`),
- {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify(metadata),
- },
- )
-
- if (res.status === 401) {
- throw new Error('Unauthorized')
- }
-
- if (!res.ok) {
- throw new Error('Failed to update game metadata')
- }
-}
-
-export interface StoreCustomGameRequest {
- name?: string
- pgn?: string
- fen?: string
-}
-
-export interface StoredCustomGameResponse {
- id: string
- name: string
- pgn?: string
- fen?: string
- created_at: string
-}
-
-export const storeCustomGame = async (
- data: StoreCustomGameRequest,
-): Promise => {
- const res = await fetch(buildUrl('analysis/store_custom_game'), {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify(data),
- })
-
- if (res.status === 401) {
- throw new Error('Unauthorized')
- }
-
- if (!res.ok) {
- const errorText = await res.text()
- throw new Error(`Failed to store custom game: ${errorText}`)
- }
-
- return res.json() as Promise
-}
diff --git a/src/api/analysis/index.ts b/src/api/analysis/index.ts
deleted file mode 100644
index 87dd896f..00000000
--- a/src/api/analysis/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from './analysis'
diff --git a/src/api/auth/auth.ts b/src/api/auth.ts
similarity index 61%
rename from src/api/auth/auth.ts
rename to src/api/auth.ts
index 2fa21c99..6adbba08 100644
--- a/src/api/auth/auth.ts
+++ b/src/api/auth.ts
@@ -1,4 +1,4 @@
-import { buildUrl } from 'src/api'
+import { buildUrl } from './utils'
const parseAccountInfo = (data: { [x: string]: string }) => {
const clientId = data['client_id']
@@ -12,29 +12,22 @@ const parseAccountInfo = (data: { [x: string]: string }) => {
}
}
-export const getAccount = async () => {
+export const fetchAccount = async () => {
const res = await fetch(buildUrl('auth/account'))
const data = await res.json()
return parseAccountInfo(data)
}
-export const logoutAndGetAccount = async () => {
+export const logoutAndFetchAccount = async () => {
await fetch(buildUrl('auth/logout'))
- return getAccount()
+ return fetchAccount()
}
-export const getLeaderboard = async () => {
+export const fetchLeaderboard = async () => {
const res = await fetch(buildUrl('auth/leaderboard'))
const data = await res.json()
return data
}
-
-export const getGlobalStats = async () => {
- const res = await fetch(buildUrl('auth/global_stats'))
- const data = await res.json()
-
- return data
-}
diff --git a/src/api/auth/index.ts b/src/api/auth/index.ts
deleted file mode 100644
index f140b2ec..00000000
--- a/src/api/auth/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from './auth'
diff --git a/src/api/home/activeUsers.ts b/src/api/home.ts
similarity index 66%
rename from src/api/home/activeUsers.ts
rename to src/api/home.ts
index 5d2f963b..0f359c60 100644
--- a/src/api/home/activeUsers.ts
+++ b/src/api/home.ts
@@ -1,7 +1,5 @@
-/**
- * Get the count of active users in the last 30 minutes
- * Calls our secure server-side API endpoint that handles PostHog integration
- */
+import { buildUrl } from './utils'
+
export const getActiveUserCount = async (): Promise => {
try {
const response = await fetch('/api/active-users')
@@ -17,3 +15,10 @@ export const getActiveUserCount = async (): Promise => {
return 0
}
+
+export const getGlobalStats = async () => {
+ const res = await fetch(buildUrl('auth/global_stats'))
+ const data = await res.json()
+
+ return data
+}
diff --git a/src/api/home/home.ts b/src/api/home/home.ts
deleted file mode 100644
index 16794b18..00000000
--- a/src/api/home/home.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import { buildUrl } from 'src/api'
-
-const getPlayerStats = async () => {
- const res = await fetch(buildUrl('/auth/get_player_stats'))
- const data = await res.json()
- return {
- regularRating: data.play_elo as number,
- handRating: data.hand_elo as number,
- brainRating: data.brain_elo as number,
- trainRating: data.puzzles_elo as number,
- botNotRating: data.turing_elo as number,
- }
-}
-
-export { getPlayerStats }
diff --git a/src/api/home/index.ts b/src/api/home/index.ts
deleted file mode 100644
index 66a56239..00000000
--- a/src/api/home/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export * from './home'
-export * from './activeUsers'
diff --git a/src/api/index.ts b/src/api/index.ts
index 42e636c6..875497a5 100644
--- a/src/api/index.ts
+++ b/src/api/index.ts
@@ -1,10 +1,10 @@
-export * from './utils'
export * from './analysis'
export * from './train'
export * from './auth'
export * from './turing'
export * from './play'
export * from './profile'
-export * from './opening'
+export * from './openings'
export * from './lichess'
-export { getActiveUserCount } from './home'
+export * from './home'
+export * from './utils'
diff --git a/src/api/lichess.ts b/src/api/lichess.ts
new file mode 100644
index 00000000..211e7eca
--- /dev/null
+++ b/src/api/lichess.ts
@@ -0,0 +1,82 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import { readLichessStream } from 'src/lib'
+import { StreamedGame, StreamedMove } from 'src/types'
+
+export const fetchLichessTVGame = async () => {
+ const res = await fetch('https://lichess.org/api/tv/channels')
+ if (!res.ok) {
+ throw new Error('Failed to fetch Lichess TV data')
+ }
+ const data = await res.json()
+
+ // Return the best rapid game (highest rated players)
+ const bestChannel = data.rapid
+ if (!bestChannel?.gameId) {
+ throw new Error('No TV game available')
+ }
+
+ return {
+ gameId: bestChannel.gameId,
+ white: bestChannel.user1,
+ black: bestChannel.user2,
+ }
+}
+
+export const fetchLichessGameInfo = async (gameId: string) => {
+ const res = await fetch(`https://lichess.org/api/game/${gameId}`)
+ if (!res.ok) {
+ throw new Error(`Failed to fetch game info for ${gameId}`)
+ }
+ return res.json()
+}
+
+export const streamLichessGameMoves = async (
+ gameId: string,
+ onGameInfo: (data: StreamedGame) => void,
+ onMove: (data: StreamedMove) => void,
+ onComplete: () => void,
+ abortSignal?: AbortSignal,
+) => {
+ const stream = fetch(`https://lichess.org/api/stream/game/${gameId}`, {
+ signal: abortSignal,
+ headers: {
+ Accept: 'application/x-ndjson',
+ },
+ })
+
+ const onMessage = (message: any) => {
+ if (message.id) {
+ onGameInfo(message as StreamedGame)
+ } else if (message.uci || message.lm) {
+ onMove({
+ fen: message.fen,
+ uci: message.uci || message.lm,
+ wc: message.wc,
+ bc: message.bc,
+ })
+ } else {
+ console.log('Unknown message format:', message)
+ }
+ }
+
+ try {
+ const response = await stream
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`)
+ }
+
+ if (!response.body) {
+ throw new Error('No response body')
+ }
+
+ await readLichessStream(onMessage)(response).then(onComplete)
+ } catch (error) {
+ if (abortSignal?.aborted) {
+ console.log('Stream aborted')
+ } else {
+ console.error('Stream error:', error)
+ throw error
+ }
+ }
+}
diff --git a/src/api/lichess/index.ts b/src/api/lichess/index.ts
deleted file mode 100644
index 7f1dfdc3..00000000
--- a/src/api/lichess/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from './streaming'
diff --git a/src/api/lichess/streaming.ts b/src/api/lichess/streaming.ts
deleted file mode 100644
index d262e1da..00000000
--- a/src/api/lichess/streaming.ts
+++ /dev/null
@@ -1,242 +0,0 @@
-/* eslint-disable @typescript-eslint/no-explicit-any */
-import { Chess } from 'chess.ts'
-import { GameTree } from 'src/types/base/tree'
-import { AvailableMoves } from 'src/types/training'
-import {
- LiveGame,
- Player,
- StockfishEvaluation,
- StreamedGame,
- StreamedMove,
-} from 'src/types'
-
-const readStream = (processLine: (data: any) => void) => (response: any) => {
- const stream = response.body.getReader()
- const matcher = /\r?\n/
- const decoder = new TextDecoder()
- let buf = ''
-
- const loop = () =>
- stream.read().then(({ done, value }: { done: boolean; value: any }) => {
- if (done) {
- if (buf.length > 0) processLine(JSON.parse(buf))
- } else {
- const chunk = decoder.decode(value, {
- stream: true,
- })
- buf += chunk
-
- const parts = (buf || '').split(matcher)
- buf = parts.pop() as string
- for (const i of parts.filter((p) => p)) processLine(JSON.parse(i))
-
- return loop()
- }
- })
-
- return loop()
-}
-
-export const getLichessTVGame = async () => {
- const res = await fetch('https://lichess.org/api/tv/channels')
- if (!res.ok) {
- throw new Error('Failed to fetch Lichess TV data')
- }
- const data = await res.json()
-
- // Return the best rapid game (highest rated players)
- const bestChannel = data.rapid
- if (!bestChannel?.gameId) {
- throw new Error('No TV game available')
- }
-
- return {
- gameId: bestChannel.gameId,
- white: bestChannel.user1,
- black: bestChannel.user2,
- }
-}
-
-export const getLichessGameInfo = async (gameId: string) => {
- const res = await fetch(`https://lichess.org/api/game/${gameId}`)
- if (!res.ok) {
- throw new Error(`Failed to fetch game info for ${gameId}`)
- }
- return res.json()
-}
-
-export const streamLichessGame = async (
- gameId: string,
- onGameInfo: (data: StreamedGame) => void,
- onMove: (data: StreamedMove) => void,
- onComplete: () => void,
- abortSignal?: AbortSignal,
-) => {
- const stream = fetch(`https://lichess.org/api/stream/game/${gameId}`, {
- signal: abortSignal,
- headers: {
- Accept: 'application/x-ndjson',
- },
- })
-
- const onMessage = (message: any) => {
- if (message.id) {
- onGameInfo(message as StreamedGame)
- } else if (message.uci || message.lm) {
- onMove({
- fen: message.fen,
- uci: message.uci || message.lm,
- wc: message.wc,
- bc: message.bc,
- })
- } else {
- console.log('Unknown message format:', message)
- }
- }
-
- try {
- const response = await stream
-
- if (!response.ok) {
- throw new Error(`HTTP ${response.status}: ${response.statusText}`)
- }
-
- if (!response.body) {
- throw new Error('No response body')
- }
-
- await readStream(onMessage)(response).then(onComplete)
- } catch (error) {
- if (abortSignal?.aborted) {
- console.log('Stream aborted')
- } else {
- console.error('Stream error:', error)
- throw error
- }
- }
-}
-
-export const createAnalyzedGameFromLichessStream = (
- gameData: any,
-): LiveGame => {
- const { players, id } = gameData
-
- const whitePlayer: Player = {
- name: players?.white?.user?.id || 'White',
- rating: players?.white?.rating,
- }
-
- const blackPlayer: Player = {
- name: players?.black?.user?.id || 'Black',
- rating: players?.black?.rating,
- }
-
- const startingFen =
- gameData.initialFen ||
- 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'
-
- const gameStates = [
- {
- board: startingFen,
- lastMove: undefined as [string, string] | undefined,
- san: undefined as string | undefined,
- check: false as const,
- maia_values: {},
- },
- ]
-
- const tree = new GameTree(startingFen)
-
- return {
- id,
- blackPlayer,
- whitePlayer,
- gameType: 'stream',
- type: 'stream' as const,
- moves: gameStates,
- availableMoves: new Array(gameStates.length).fill({}) as AvailableMoves[],
- termination: undefined,
- maiaEvaluations: [],
- stockfishEvaluations: [],
- loadedFen: gameData.fen,
- loaded: false,
- tree,
- } as LiveGame
-}
-
-export const parseLichessStreamMove = (
- moveData: StreamedMove,
- currentGame: LiveGame,
-) => {
- const { uci, fen } = moveData
-
- if (!uci || !fen || !currentGame.tree) {
- return currentGame
- }
-
- // Convert UCI to SAN notation using chess.js
- let san = uci // Fallback to UCI
- try {
- // Get the position before this move by finding the last node in the tree
- let beforeMoveNode = currentGame.tree.getRoot()
- while (beforeMoveNode.mainChild) {
- beforeMoveNode = beforeMoveNode.mainChild
- }
-
- const chess = new Chess(beforeMoveNode.fen)
- const move = chess.move({
- from: uci.slice(0, 2),
- to: uci.slice(2, 4),
- promotion: uci[4] ? (uci[4] as any) : undefined,
- })
-
- if (move) {
- san = move.san
- }
- } catch (error) {
- console.warn('Could not convert UCI to SAN:', error)
- // Keep UCI as fallback
- }
-
- // Create new move object
- const newMove = {
- board: fen,
- lastMove: [uci.slice(0, 2), uci.slice(2, 4)] as [string, string],
- san: san,
- check: false as const, // We'd need to calculate this from the FEN
- maia_values: {},
- }
-
- // Add to moves array
- const updatedMoves = [...currentGame.moves, newMove]
-
- // Add to tree mainline - find the last node in the main line
- let currentNode = currentGame.tree.getRoot()
- while (currentNode.mainChild) {
- currentNode = currentNode.mainChild
- }
-
- try {
- currentGame.tree.addMainMove(currentNode, fen, uci, san)
- } catch (error) {
- console.error('Error adding move to tree:', error)
- // Return current game if tree update fails
- return currentGame
- }
-
- // Update available moves and evaluations arrays
- const updatedAvailableMoves = [...currentGame.availableMoves, {}]
- const updatedMaiaEvaluations = [...currentGame.maiaEvaluations, {}]
- const updatedStockfishEvaluations = [
- ...currentGame.stockfishEvaluations,
- undefined,
- ] as (StockfishEvaluation | undefined)[]
-
- return {
- ...currentGame,
- moves: updatedMoves,
- availableMoves: updatedAvailableMoves,
- maiaEvaluations: updatedMaiaEvaluations,
- stockfishEvaluations: updatedStockfishEvaluations,
- }
-}
diff --git a/src/api/opening/index.ts b/src/api/opening/index.ts
deleted file mode 100644
index 955f883d..00000000
--- a/src/api/opening/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from './opening'
diff --git a/src/api/opening/opening.ts b/src/api/openings.ts
similarity index 97%
rename from src/api/opening/opening.ts
rename to src/api/openings.ts
index 8ac24884..4945ff8e 100644
--- a/src/api/opening/opening.ts
+++ b/src/api/openings.ts
@@ -1,4 +1,4 @@
-import { buildUrl } from '../utils'
+import { buildUrl } from './utils'
// API Types for opening drill logging
export interface OpeningDrillSelection {
diff --git a/src/api/play/play.ts b/src/api/play.ts
similarity index 95%
rename from src/api/play/play.ts
rename to src/api/play.ts
index 7c6d264c..1a56cfeb 100644
--- a/src/api/play/play.ts
+++ b/src/api/play.ts
@@ -1,4 +1,4 @@
-import { buildUrl } from '../utils'
+import { buildUrl } from './utils'
import { Color, TimeControl } from 'src/types'
export const startGame = async (
@@ -48,7 +48,7 @@ export const startGame = async (
}
}
-export const getGameMove = async (
+export const fetchGameMove = async (
moves: string[],
maiaVersion = 'maia_kdd_1900',
fen: string | null = null,
@@ -106,7 +106,7 @@ export const getGameMove = async (
return res.json()
}
-export const getBookMoves = async (fen: string) => {
+export const fetchOpeningBookMoves = async (fen: string) => {
const res = await fetch(buildUrl(`play/get_book_moves?fen=${fen}`), {
method: 'POST',
headers: {
@@ -132,7 +132,7 @@ export const getBookMoves = async (fen: string) => {
return res.json()
}
-export const submitGameMove = async (
+export const logGameMove = async (
gameId: string,
moves: string[],
moveTimes: number[],
@@ -164,7 +164,7 @@ export const submitGameMove = async (
return res.json()
}
-export const getPlayPlayerStats = async () => {
+export const fetchPlayPlayerStats = async () => {
const res = await fetch(buildUrl('play/get_player_stats'))
const data = await res.json()
return {
diff --git a/src/api/play/index.ts b/src/api/play/index.ts
deleted file mode 100644
index 1cf1ab44..00000000
--- a/src/api/play/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from './play'
diff --git a/src/api/profile/profile.ts b/src/api/profile.ts
similarity index 93%
rename from src/api/profile/profile.ts
rename to src/api/profile.ts
index 407e866c..e72f4309 100644
--- a/src/api/profile/profile.ts
+++ b/src/api/profile.ts
@@ -1,7 +1,7 @@
-import { buildUrl } from '../utils'
+import { buildUrl } from './utils'
import { PlayerStats } from 'src/types'
-export const getPlayerStats = async (name?: string): Promise => {
+export const fetchPlayerStats = async (name?: string): Promise => {
const res = await fetch(
buildUrl(`auth/get_player_stats${name ? `/${name}` : ''}`),
)
diff --git a/src/api/profile/index.ts b/src/api/profile/index.ts
deleted file mode 100644
index 060535fd..00000000
--- a/src/api/profile/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from './profile'
diff --git a/src/api/train/train.ts b/src/api/train.ts
similarity index 86%
rename from src/api/train/train.ts
rename to src/api/train.ts
index 0dcf778d..9a29f8e5 100644
--- a/src/api/train/train.ts
+++ b/src/api/train.ts
@@ -1,10 +1,9 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
-import { Chess } from 'chess.ts'
-import { MoveMap, GameTree } from 'src/types'
-import { AvailableMoves, TrainingGame } from 'src/types/training'
-import { buildUrl } from '../utils'
+import { buildUrl } from './utils'
+import { MoveValueMapping, GameTree } from 'src/types'
+import { AvailableMoves, PuzzleGame } from 'src/types/puzzle'
-export const getTrainingGame = async () => {
+export const fetchPuzzle = async () => {
const res = await fetch(buildUrl('puzzle/new_puzzle'))
const data = await res.json()
const id =
@@ -62,19 +61,16 @@ export const getTrainingGame = async () => {
for (let i = 1; i < moves.length; i++) {
const move = moves[i]
if (move.uci && move.san) {
- currentNode = gameTree.addMainMove(
- currentNode,
- move.board,
- move.uci,
- move.san,
- )
+ currentNode = gameTree
+ .getLastMainlineNode()
+ .addChild(move.board, move.uci, move.san, true)
}
}
const moveMap = data['target_move_map']
- const stockfishEvaluation: MoveMap = {}
- const maiaEvaluation: MoveMap = {}
+ const stockfishEvaluation: MoveValueMapping = {}
+ const maiaEvaluation: MoveValueMapping = {}
const availableMoves: AvailableMoves = {}
moveMap.forEach(
@@ -112,7 +108,7 @@ export const getTrainingGame = async () => {
termination,
availableMoves,
targetIndex: data['target_move_index'],
- } as any as TrainingGame
+ } as any as PuzzleGame
}
export const logPuzzleGuesses = async (
@@ -150,7 +146,7 @@ export const logPuzzleGuesses = async (
return res.json()
}
-export const getTrainingPlayerStats = async () => {
+export const fetchTrainingPlayerStats = async () => {
const res = await fetch(buildUrl('puzzle/get_player_stats'))
const data = await res.json()
return {
diff --git a/src/api/train/index.ts b/src/api/train/index.ts
deleted file mode 100644
index 2bf07805..00000000
--- a/src/api/train/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from './train'
diff --git a/src/api/turing/turing.ts b/src/api/turing.ts
similarity index 91%
rename from src/api/turing/turing.ts
rename to src/api/turing.ts
index 8b4316b4..e3162b32 100644
--- a/src/api/turing/turing.ts
+++ b/src/api/turing.ts
@@ -1,7 +1,7 @@
+import { buildUrl } from './utils'
import { Color, TuringGame, TuringSubmissionResult, GameTree } from 'src/types'
-import { buildUrl } from 'src/api'
-export const getTuringGame = async () => {
+export const fetchTuringGame = async () => {
const res = await fetch(buildUrl('turing/new_game'))
if (res.status === 401) {
@@ -46,12 +46,9 @@ export const getTuringGame = async () => {
for (let i = 1; i < moves.length; i++) {
const move = moves[i]
if (move.uci && move.san) {
- currentNode = gameTree.addMainMove(
- currentNode,
- move.board,
- move.uci,
- move.san,
- )
+ currentNode = gameTree
+ .getLastMainlineNode()
+ .addChild(move.board, move.uci, move.san, true)
}
}
@@ -114,7 +111,7 @@ export const submitTuringGuess = async (
} as TuringSubmissionResult
}
-export const getTuringPlayerStats = async () => {
+export const fetchTuringPlayerStats = async () => {
const res = await fetch(buildUrl('turing/get_player_stats'))
const data = await res.json()
return {
diff --git a/src/api/turing/index.ts b/src/api/turing/index.ts
deleted file mode 100644
index 230f2233..00000000
--- a/src/api/turing/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from './turing'
diff --git a/src/components/Analysis/AnalysisGameList.tsx b/src/components/Analysis/AnalysisGameList.tsx
index a523fbd1..66611a4c 100644
--- a/src/components/Analysis/AnalysisGameList.tsx
+++ b/src/components/Analysis/AnalysisGameList.tsx
@@ -11,16 +11,13 @@ import { motion } from 'framer-motion'
import { Tournament } from 'src/components'
import { FavoriteModal } from 'src/components/Common/FavoriteModal'
import { AnalysisListContext } from 'src/contexts'
-import { getAnalysisGameList } from 'src/api'
-import { ensureMigration } from 'src/lib/customAnalysis'
+import { fetchMaiaGameList } from 'src/api'
import {
getFavoritesAsWebGames,
addFavoriteGame,
removeFavoriteGame,
- updateFavoriteName,
- isFavoriteGame,
} from 'src/lib/favorites'
-import { AnalysisWebGame } from 'src/types'
+import { MaiaGameListEntry } from 'src/types'
import { useRouter } from 'next/router'
interface GameData {
@@ -34,24 +31,20 @@ interface GameData {
interface AnalysisGameListProps {
currentId: string[] | null
- loadNewTournamentGame: (
+ loadNewWorldChampionshipGame: (
newId: string[],
setCurrentMove?: Dispatch>,
) => Promise
- loadNewLichessGames: (
+ loadNewLichessGame: (
id: string,
pgn: string,
setCurrentMove?: Dispatch>,
) => Promise
- loadNewUserGames: (
+ loadNewMaiaGame: (
id: string,
type: 'play' | 'hand' | 'brain',
setCurrentMove?: Dispatch>,
) => Promise
- loadNewCustomGame: (
- id: string,
- setCurrentMove?: Dispatch>,
- ) => Promise
onCustomAnalysis?: () => void
onGameSelected?: () => void // Called when a game is selected (for mobile popup closing)
refreshTrigger?: number // Used to trigger refresh when custom analysis is added
@@ -59,29 +52,21 @@ interface AnalysisGameListProps {
export const AnalysisGameList: React.FC = ({
currentId,
- loadNewTournamentGame,
- loadNewLichessGames,
- loadNewUserGames,
- loadNewCustomGame,
onCustomAnalysis,
onGameSelected,
refreshTrigger,
+ loadNewWorldChampionshipGame,
}) => {
const router = useRouter()
- const {
- analysisPlayList,
- analysisHandList,
- analysisBrainList,
- analysisLichessList,
- analysisTournamentList,
- } = useContext(AnalysisListContext)
+ const { analysisLichessList, analysisTournamentList } =
+ useContext(AnalysisListContext)
const [currentPage, setCurrentPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const [loading, setLoading] = useState(false)
const [gamesByPage, setGamesByPage] = useState<{
- [gameType: string]: { [page: number]: AnalysisWebGame[] }
+ [gameType: string]: { [page: number]: MaiaGameListEntry[] }
}>({
play: {},
hand: {},
@@ -90,20 +75,18 @@ export const AnalysisGameList: React.FC = ({
custom: {},
})
- const [favoriteGames, setFavoriteGames] = useState([])
+ const [favoriteGames, setFavoriteGames] = useState([])
const [favoritedGameIds, setFavoritedGameIds] = useState>(
new Set(),
)
const [hbSubsection, setHbSubsection] = useState<'hand' | 'brain'>('hand')
- // Modal state for favoriting
const [favoriteModal, setFavoriteModal] = useState<{
isOpen: boolean
- game: AnalysisWebGame | null
+ game: MaiaGameListEntry | null
}>({ isOpen: false, game: null })
useEffect(() => {
- // Load favorites asynchronously
getFavoritesAsWebGames()
.then((favorites) => {
setFavoriteGames(favorites)
@@ -115,12 +98,6 @@ export const AnalysisGameList: React.FC = ({
})
}, [refreshTrigger])
- useEffect(() => {
- ensureMigration().catch((error) => {
- console.warn('Failed to migrate custom analyses:', error)
- })
- }, [])
-
useEffect(() => {
if (currentId?.[1] === 'custom') {
setSelected('custom')
@@ -224,15 +201,15 @@ export const AnalysisGameList: React.FC = ({
[selected]: { ...prev[selected], [currentPage]: true },
}))
- getAnalysisGameList(selected, currentPage)
+ fetchMaiaGameList(selected, currentPage)
.then((data) => {
- let parsedGames: AnalysisWebGame[] = []
+ console.log(data)
+ let parsedGames: MaiaGameListEntry[] = []
if (selected === 'favorites') {
- // Handle favorites response format
parsedGames = data.games.map((game: any) => ({
id: game.game_id || game.id,
- type: game.game_type || game.type || 'custom-pgn',
+ type: game.game_type || game.type,
label: game.custom_name || game.label || 'Untitled',
result: game.result || '*',
pgn: game.pgn,
@@ -240,15 +217,14 @@ export const AnalysisGameList: React.FC = ({
custom_name: game.custom_name,
}))
} else {
- // Handle regular games response format
-
if (selected === 'custom') {
parsedGames = data.games.map((game: any) => ({
- id: game.id,
- label: game.name || 'Custom Game',
- result: '*',
- type: game.pgn ? 'custom-pgn' : 'custom-fen',
- pgn: game.pgn,
+ id: game.game_id || game.id,
+ type: 'custom',
+ label: game.custom_name || 'Custom Game',
+ result: game.result || '*',
+ is_favorited: game.is_favorited,
+ custom_name: game.custom_name,
}))
} else {
const parse = (
@@ -265,7 +241,6 @@ export const AnalysisGameList: React.FC = ({
const raw = game.maia_name.replace('_kdd_', ' ')
const maia = raw.charAt(0).toUpperCase() + raw.slice(1)
- // Use custom name if available, otherwise generate default label
const defaultLabel =
game.player_color === 'white'
? `You vs. ${maia}`
@@ -302,7 +277,6 @@ export const AnalysisGameList: React.FC = ({
},
}))
- // Update favoritedGameIds from the actual games data
const favoritedIds = new Set(
parsedGames
.filter((game: any) => game.is_favorited)
@@ -326,7 +300,6 @@ export const AnalysisGameList: React.FC = ({
}
}, [selected, currentPage, fetchedCache])
- // Separate useEffect for H&B subsections
useEffect(() => {
if (selected === 'hb') {
const gameType = hbSubsection === 'hand' ? 'hand' : 'brain'
@@ -340,7 +313,7 @@ export const AnalysisGameList: React.FC = ({
[gameType]: { ...prev[gameType], [currentPage]: true },
}))
- getAnalysisGameList(gameType, currentPage)
+ fetchMaiaGameList(gameType, currentPage)
.then((data) => {
const parse = (
game: {
@@ -356,7 +329,6 @@ export const AnalysisGameList: React.FC = ({
const raw = game.maia_name.replace('_kdd_', ' ')
const maia = raw.charAt(0).toUpperCase() + raw.slice(1)
- // Use custom name if available, otherwise generate default label
const defaultLabel =
game.player_color === 'white'
? `You vs. ${maia}`
@@ -391,7 +363,6 @@ export const AnalysisGameList: React.FC = ({
},
}))
- // Update favoritedGameIds from the actual games data
const favoritedIds = new Set(
parsedGames
.filter((game: any) => game.is_favorited)
@@ -463,7 +434,7 @@ export const AnalysisGameList: React.FC = ({
setSelected(newTab)
}
- const handleFavoriteGame = (game: AnalysisWebGame) => {
+ const handleFavoriteGame = (game: MaiaGameListEntry) => {
setFavoriteModal({ isOpen: true, game })
}
@@ -511,7 +482,6 @@ export const AnalysisGameList: React.FC = ({
setFavoriteGames(updatedFavorites)
setFavoritedGameIds(new Set(updatedFavorites.map((f) => f.id)))
- // Clear favorites cache to force re-fetch
setFetchedCache((prev) => ({
...prev,
favorites: {},
@@ -521,7 +491,6 @@ export const AnalysisGameList: React.FC = ({
favorites: {},
}))
- // Also clear current section cache to show updated favorite status
if (selected !== 'favorites') {
const currentSection =
selected === 'hb'
@@ -541,7 +510,7 @@ export const AnalysisGameList: React.FC = ({
}
}
- const handleDirectUnfavorite = async (game: AnalysisWebGame) => {
+ const handleDirectUnfavorite = async (game: MaiaGameListEntry) => {
await removeFavoriteGame(game.id, game.type)
const updatedFavorites = await getFavoritesAsWebGames()
setFavoriteGames(updatedFavorites)
@@ -720,7 +689,6 @@ export const AnalysisGameList: React.FC = ({
selectedGameElement={
selectedGameElement as React.RefObject
}
- loadNewTournamentGame={loadNewTournamentGame}
analysisTournamentList={analysisTournamentList}
/>
))}
@@ -730,7 +698,8 @@ export const AnalysisGameList: React.FC = ({
{getCurrentGames().map((game, index) => {
const selectedGame = currentId && currentId[0] === game.id
const isFavorited = (game as any).is_favorited || false
- const displayName = game.label // This now contains the custom name if favorited
+ const displayName = game.label
+ // console.log(game)
return (
= ({
{
setLoadingIndex(index)
- if (game.type === 'pgn') {
- router.push(`/analysis/${game.id}/pgn`)
- } else if (
- game.type === 'custom-pgn' ||
- game.type === 'custom-fen'
- ) {
+ if (game.type === 'lichess') {
+ router.push(`/analysis/${game.id}/lichess`)
+ } else if (game.type === 'custom') {
router.push(`/analysis/${game.id}/custom`)
} else {
router.push(`/analysis/${game.id}/${game.type}`)
diff --git a/src/components/Analysis/ConfigureAnalysis.tsx b/src/components/Analysis/ConfigureAnalysis.tsx
index 601137bb..63e13852 100644
--- a/src/components/Analysis/ConfigureAnalysis.tsx
+++ b/src/components/Analysis/ConfigureAnalysis.tsx
@@ -34,8 +34,6 @@ export const ConfigureAnalysis: React.FC = ({
isLearnFromMistakesActive = false,
autoSave,
}: Props) => {
- const isCustomGame = game.type === 'custom-pgn' || game.type === 'custom-fen'
-
return (
@@ -90,44 +88,41 @@ export const ConfigureAnalysis: React.FC
= ({
)}
- {autoSave &&
- game.type !== 'custom-pgn' &&
- game.type !== 'custom-fen' &&
- game.type !== 'tournament' && (
-
-
- {autoSave.status === 'saving' && (
- <>
-
-
- Saving analysis...
-
- >
- )}
- {autoSave.status === 'unsaved' && (
- <>
-
- sync_problem
-
-
- Unsaved analysis. Will auto-save...
-
- >
- )}
- {autoSave.status === 'saved' && (
- <>
-
- cloud_done
-
-
- Analysis auto-saved
-
- >
- )}
-
+ {autoSave && game.type !== 'tournament' && (
+
+
+ {autoSave.status === 'saving' && (
+ <>
+
+
+ Saving analysis...
+
+ >
+ )}
+ {autoSave.status === 'unsaved' && (
+ <>
+
+ sync_problem
+
+
+ Unsaved analysis. Will auto-save...
+
+ >
+ )}
+ {autoSave.status === 'saved' && (
+ <>
+
+ cloud_done
+
+
+ Analysis auto-saved
+
+ >
+ )}
- )}
- {isCustomGame && onDeleteCustomGame && (
+
+ )}
+ {game.type === 'custom' && onDeleteCustomGame && (
= ({
} else {
// For stream analysis, ALWAYS create variations for player moves
// This preserves the live game mainline and allows exploration
- const newVariation = game.tree.addVariation(
+ const newVariation = game.tree.addVariationNode(
analysisController.currentNode,
newFen,
moveString,
diff --git a/src/components/Analysis/Tournament.tsx b/src/components/Analysis/Tournament.tsx
index 66c31779..d23aedca 100644
--- a/src/components/Analysis/Tournament.tsx
+++ b/src/components/Analysis/Tournament.tsx
@@ -1,5 +1,5 @@
import { Dispatch, SetStateAction } from 'react'
-import { AnalysisTournamentGame } from 'src/types'
+import { WorldChampionshipGameListEntry } from 'src/types'
import { useRouter } from 'next/router'
type Props = {
id: string
@@ -11,11 +11,7 @@ type Props = {
setLoadingIndex: (index: number | null) => void
openElement: React.RefObject
selectedGameElement: React.RefObject
- analysisTournamentList: Map
- loadNewTournamentGame: (
- id: string[],
- setCurrentMove?: Dispatch>,
- ) => Promise
+ analysisTournamentList: Map
setCurrentMove?: Dispatch>
}
@@ -30,8 +26,6 @@ export const Tournament = ({
setLoadingIndex,
selectedGameElement,
analysisTournamentList,
- loadNewTournamentGame,
- setCurrentMove,
}: Props) => {
const router = useRouter()
const games = analysisTournamentList.get(id)
diff --git a/src/components/Board/GameBoard.tsx b/src/components/Board/GameBoard.tsx
index 7867e5cc..64c6ebd3 100644
--- a/src/components/Board/GameBoard.tsx
+++ b/src/components/Board/GameBoard.tsx
@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Chess } from 'chess.ts'
-import { chessSoundManager } from 'src/lib/chessSoundManager'
+import { chessSoundManager } from 'src/lib/sound'
import { defaults } from 'chessground/state'
import type { Key } from 'chessground/types'
import Chessground from '@react-chess/chessground'
@@ -66,7 +66,7 @@ export const GameBoard: React.FC = ({
if (currentNode.mainChild?.move === moveString) {
goToNode(currentNode.mainChild)
} else {
- const newVariation = game.tree.addVariation(
+ const newVariation = game.tree.addVariationNode(
currentNode,
newFen,
moveString,
diff --git a/src/components/Board/GameClock.tsx b/src/components/Board/GameClock.tsx
index c35ea39d..0adb1a2c 100644
--- a/src/components/Board/GameClock.tsx
+++ b/src/components/Board/GameClock.tsx
@@ -2,7 +2,7 @@ import { useState, useEffect, useContext } from 'react'
import { Color } from 'src/types'
import { AuthContext } from 'src/contexts'
-import { PlayControllerContext } from 'src/contexts/PlayControllerContext/PlayControllerContext'
+import { PlayControllerContext } from 'src/contexts/PlayControllerContext'
interface Props {
player: Color
diff --git a/src/components/Board/GameplayInterface.tsx b/src/components/Board/GameplayInterface.tsx
index 326f45cd..91560e0e 100644
--- a/src/components/Board/GameplayInterface.tsx
+++ b/src/components/Board/GameplayInterface.tsx
@@ -13,7 +13,7 @@ import { useUnload } from 'src/hooks/useUnload'
import type { DrawShape } from 'chessground/draw'
import { useCallback, useContext, useMemo, useState } from 'react'
import { AuthContext, WindowSizeContext } from 'src/contexts'
-import { PlayControllerContext } from 'src/contexts/PlayControllerContext/PlayControllerContext'
+import { PlayControllerContext } from 'src/contexts/PlayControllerContext'
interface Props {
boardShapes?: DrawShape[]
diff --git a/src/components/Board/MovesContainer.tsx b/src/components/Board/MovesContainer.tsx
index 0b4096e4..546a76f5 100644
--- a/src/components/Board/MovesContainer.tsx
+++ b/src/components/Board/MovesContainer.tsx
@@ -1,10 +1,9 @@
/* eslint-disable jsx-a11y/no-static-element-interactions */
/* eslint-disable jsx-a11y/click-events-have-key-events */
+import { TuringGame } from 'src/types/turing'
import React, { useContext, useMemo, Fragment, useEffect, useRef } from 'react'
-import { WindowSizeContext } from 'src/contexts'
+import { TreeControllerContext, WindowSizeContext } from 'src/contexts'
import { GameNode, AnalyzedGame, Termination, BaseGame } from 'src/types'
-import { TuringGame } from 'src/types/turing'
-import { useBaseTreeController } from 'src/hooks/useBaseTreeController'
import { MoveClassificationIcon } from 'src/components/Common/MoveIcons'
interface AnalysisProps {
@@ -92,7 +91,7 @@ export const MovesContainer: React.FC = (props) => {
return plyFromStart >= 6
}
- const baseController = useBaseTreeController(type)
+ const baseController = useContext(TreeControllerContext)
const mainLineNodes = useMemo(() => {
return baseController.gameTree.getMainLine() ?? game.tree.getMainLine()
diff --git a/src/components/Common/ExportGame.tsx b/src/components/Common/ExportGame.tsx
index d73bf87d..c139b776 100644
--- a/src/components/Common/ExportGame.tsx
+++ b/src/components/Common/ExportGame.tsx
@@ -4,7 +4,7 @@ import toast from 'react-hot-toast'
import { useContext, useEffect, useState } from 'react'
import { PlayedGame, AnalyzedGame, GameTree, GameNode } from 'src/types'
-import { useBaseTreeController } from 'src/hooks/useBaseTreeController'
+import { TreeControllerContext } from 'src/contexts'
interface AnalysisProps {
game: AnalyzedGame
@@ -41,7 +41,7 @@ export const ExportGame: React.FC = (props) => {
const [fen, setFen] = useState('')
const [pgn, setPgn] = useState('')
- const controller = useBaseTreeController(type)
+ const controller = useContext(TreeControllerContext)
const { currentNode, gameTree } =
type === 'analysis'
@@ -81,7 +81,6 @@ export const ExportGame: React.FC = (props) => {
setFen(currentNode.fen)
}, [
currentNode,
- game.moves,
game.id,
game.termination,
whitePlayer,
diff --git a/src/components/Home/LiveChessBoard.tsx b/src/components/Home/LiveChessBoard.tsx
index ddd406ae..daba384a 100644
--- a/src/components/Home/LiveChessBoard.tsx
+++ b/src/components/Home/LiveChessBoard.tsx
@@ -3,7 +3,7 @@ import { useRouter } from 'next/router'
import { AnimatePresence, motion } from 'framer-motion'
import { Chess } from 'chess.ts'
import Chessground from '@react-chess/chessground'
-import { getLichessTVGame, streamLichessGame } from 'src/api/lichess/streaming'
+import { fetchLichessTVGame, streamLichessGameMoves } from 'src/api/lichess'
import { StreamedGame, StreamedMove } from 'src/types/stream'
interface LiveGameData {
@@ -63,7 +63,7 @@ export const LiveChessBoard: React.FC = () => {
const fetchNewGame = useCallback(async () => {
try {
setError(null)
- const tvGame = await getLichessTVGame()
+ const tvGame = await fetchLichessTVGame()
// Stop current stream if any
if (abortController.current) {
@@ -80,7 +80,7 @@ export const LiveChessBoard: React.FC = () => {
isLive: true,
})
- streamLichessGame(
+ streamLichessGameMoves(
tvGame.gameId,
handleGameStart,
handleMove,
diff --git a/src/components/Home/LiveChessWidget.tsx b/src/components/Home/LiveChessWidget.tsx
index 6ecd3320..31238628 100644
--- a/src/components/Home/LiveChessWidget.tsx
+++ b/src/components/Home/LiveChessWidget.tsx
@@ -1,7 +1,7 @@
import React, { useState, useEffect, useCallback } from 'react'
import Link from 'next/link'
import { motion } from 'framer-motion'
-import { getLichessTVGame } from 'src/api/lichess/streaming'
+import { fetchLichessTVGame } from 'src/api/lichess'
interface LiveGameData {
gameId: string
@@ -31,7 +31,7 @@ export const LiveChessWidget: React.FC = () => {
setIsLoading(true)
try {
setError(null)
- const tvGame = await getLichessTVGame()
+ const tvGame = await fetchLichessTVGame()
setLiveGame({
gameId: tvGame.gameId,
diff --git a/src/components/Leaderboard/LeaderboardEntry.tsx b/src/components/Leaderboard/LeaderboardEntry.tsx
index e101d04a..389fbf04 100644
--- a/src/components/Leaderboard/LeaderboardEntry.tsx
+++ b/src/components/Leaderboard/LeaderboardEntry.tsx
@@ -2,7 +2,7 @@ import Link from 'next/link'
import { useCallback, useEffect, useRef, useState } from 'react'
import { PlayerStats } from 'src/types'
-import { getPlayerStats } from 'src/api'
+import { fetchPlayerStats } from 'src/api'
import { useLeaderboardContext } from './LeaderboardContext'
interface Props {
@@ -97,7 +97,7 @@ export const LeaderboardEntry = ({
const fetchStats = useCallback(async () => {
try {
- const playerStats = await getPlayerStats(display_name)
+ const playerStats = await fetchPlayerStats(display_name)
setStats(playerStats)
// Only show popup if we're still supposed to (user still hovering)
if (shouldShowPopupRef.current && hover) {
diff --git a/src/components/Openings/DrillPerformanceModal.tsx b/src/components/Openings/DrillPerformanceModal.tsx
index 7ae8898d..7cfa9045 100644
--- a/src/components/Openings/DrillPerformanceModal.tsx
+++ b/src/components/Openings/DrillPerformanceModal.tsx
@@ -34,7 +34,7 @@ import {
import { MOVE_CLASSIFICATION_THRESHOLDS } from 'src/constants/analysis'
import { useTreeController } from 'src/hooks'
import { generateColorSanMapping } from 'src/hooks/useAnalysisController/utils'
-import { GameNode, GameTree } from 'src/types/base/tree'
+import { GameNode, GameTree } from 'src/types/tree'
interface Props {
performanceData: DrillPerformanceData
diff --git a/src/components/Openings/OpeningDrillAnalysis.tsx b/src/components/Openings/OpeningDrillAnalysis.tsx
index 0a50f225..6bf44b22 100644
--- a/src/components/Openings/OpeningDrillAnalysis.tsx
+++ b/src/components/Openings/OpeningDrillAnalysis.tsx
@@ -7,7 +7,7 @@ import {
AnalysisSidebar,
} from '../Analysis'
import { GameNode } from 'src/types'
-import { GameTree } from 'src/types/base/tree'
+import { GameTree } from 'src/types/tree'
import type { DrawShape } from 'chessground/draw'
import { useAnalysisController } from 'src/hooks/useAnalysisController'
import { WindowSizeContext } from 'src/contexts'
diff --git a/src/components/Openings/OpeningSelectionModal.tsx b/src/components/Openings/OpeningSelectionModal.tsx
index bf49962f..b991853e 100644
--- a/src/components/Openings/OpeningSelectionModal.tsx
+++ b/src/components/Openings/OpeningSelectionModal.tsx
@@ -23,7 +23,7 @@ import {
trackDrillConfigurationCompleted,
} from 'src/lib/analytics'
import { MAIA_MODELS_WITH_NAMES } from 'src/constants/common'
-import { selectOpeningDrills } from 'src/api/opening'
+import { selectOpeningDrills } from 'src/api/openings'
type MobileTab = 'browse' | 'selected'
diff --git a/src/components/Profile/GameList.tsx b/src/components/Profile/GameList.tsx
index a84523ab..9851619c 100644
--- a/src/components/Profile/GameList.tsx
+++ b/src/components/Profile/GameList.tsx
@@ -2,15 +2,13 @@ import { motion } from 'framer-motion'
import React, { useState, useEffect, useContext } from 'react'
import { AuthContext } from 'src/contexts'
-import { AnalysisWebGame } from 'src/types'
-import { getLichessGames, getAnalysisGameList } from 'src/api'
-import { getCustomAnalysesAsWebGames } from 'src/lib/customAnalysis'
+import { MaiaGameListEntry } from 'src/types'
+import { streamLichessGames, fetchMaiaGameList } from 'src/api'
import { FavoriteModal } from 'src/components/Common/FavoriteModal'
import {
- getFavoritesAsWebGames,
addFavoriteGame,
removeFavoriteGame,
- isFavoriteGame,
+ getFavoritesAsWebGames,
} from 'src/lib/favorites'
interface GameData {
@@ -44,10 +42,10 @@ export const GameList = ({
'play' | 'hb' | 'custom' | 'lichess' | 'favorites'
>(showCustom ? 'favorites' : 'play')
const [hbSubsection, setHbSubsection] = useState<'hand' | 'brain'>('hand')
- const [games, setGames] = useState([])
+ const [games, setGames] = useState([])
const [gamesByPage, setGamesByPage] = useState<{
- [gameType: string]: { [page: number]: AnalysisWebGame[] }
+ [gameType: string]: { [page: number]: MaiaGameListEntry[] }
}>({
play: {},
hand: {},
@@ -55,13 +53,7 @@ export const GameList = ({
favorites: {},
})
- const [customAnalyses, setCustomAnalyses] = useState(() => {
- if (typeof window !== 'undefined') {
- return getCustomAnalysesAsWebGames()
- }
- return []
- })
- const [favoriteGames, setFavoriteGames] = useState([])
+ const [favoriteGames, setFavoriteGames] = useState([])
const [favoritedGameIds, setFavoritedGameIds] = useState>(
new Set(),
)
@@ -72,7 +64,7 @@ export const GameList = ({
// Modal state for favoriting
const [favoriteModal, setFavoriteModal] = useState<{
isOpen: boolean
- game: AnalysisWebGame | null
+ game: MaiaGameListEntry | null
}>({ isOpen: false, game: null })
const [fetchedCache, setFetchedCache] = useState<{
@@ -101,9 +93,6 @@ export const GameList = ({
// Update custom analyses and favorites when component mounts
useEffect(() => {
- if (showCustom) {
- setCustomAnalyses(getCustomAnalysesAsWebGames())
- }
// Load favorites (supports both sync and async implementations)
Promise.resolve(getFavoritesAsWebGames())
.then((favorites) => {
@@ -124,14 +113,14 @@ export const GameList = ({
lichess: { ...prev.lichess, 1: true },
}))
- getLichessGames(targetUser, (data) => {
+ streamLichessGames(targetUser, (data) => {
const result = data.pgn.match(/\[Result\s+"(.+?)"\]/)[1] || '?'
- const game: AnalysisWebGame = {
+ const game: MaiaGameListEntry = {
id: data.id,
label: `${data.players.white.user?.id || 'Unknown'} vs. ${data.players.black.user?.id || 'Unknown'}`,
result: result,
- type: 'pgn',
+ type: 'lichess',
}
setGames((x) => [...x, game])
@@ -153,15 +142,15 @@ export const GameList = ({
[gameType]: { ...prev[gameType], [currentPage]: true },
}))
- getAnalysisGameList(gameType, currentPage, lichessId)
+ fetchMaiaGameList(gameType, currentPage, lichessId)
.then((data) => {
- let parsedGames: AnalysisWebGame[] = []
+ let parsedGames: MaiaGameListEntry[] = []
if (gameType === 'favorites') {
// Handle favorites response format
parsedGames = data.games.map((game: any) => ({
id: game.game_id || game.id,
- type: game.game_type || game.type || 'custom-pgn',
+ type: game.game_type || game.type,
label: game.custom_name || game.label || 'Untitled',
result: game.result || '*',
pgn: game.pgn,
@@ -309,7 +298,7 @@ export const GameList = ({
setSelected(newTab)
}
- const handleFavoriteGame = (game: AnalysisWebGame) => {
+ const handleFavoriteGame = (game: MaiaGameListEntry) => {
setFavoriteModal({ isOpen: true, game })
}
@@ -377,7 +366,7 @@ export const GameList = ({
}
}
- const handleDirectUnfavorite = async (game: AnalysisWebGame) => {
+ const handleDirectUnfavorite = async (game: MaiaGameListEntry) => {
await removeFavoriteGame(game.id, game.type)
const updatedFavorites = await getFavoritesAsWebGames()
setFavoriteGames(updatedFavorites)
@@ -413,8 +402,6 @@ export const GameList = ({
} else if (selected === 'hb') {
const gameType = hbSubsection
return gamesByPage[gameType]?.[currentPage] || []
- } else if (selected === 'custom' && showCustom) {
- return customAnalyses
} else if (selected === 'lichess' && showLichess) {
return games
} else if (selected === 'favorites') {
diff --git a/src/components/Puzzles/Feedback.tsx b/src/components/Puzzles/Feedback.tsx
index d8a73390..e4fa67eb 100644
--- a/src/components/Puzzles/Feedback.tsx
+++ b/src/components/Puzzles/Feedback.tsx
@@ -3,11 +3,11 @@ import { useMemo, Dispatch, SetStateAction } from 'react'
import { Markdown } from 'src/components'
import { useTrainingController } from 'src/hooks'
-import { TrainingGame, Status } from 'src/types/training'
+import { PuzzleGame, Status } from 'src/types/puzzle'
interface Props {
status: string
- game: TrainingGame
+ game: PuzzleGame
setAndGiveUp: () => void
getNewGame: () => Promise
setStatus: Dispatch>
diff --git a/src/components/Puzzles/PuzzleLog.tsx b/src/components/Puzzles/PuzzleLog.tsx
index 9c946915..f76ffbd0 100644
--- a/src/components/Puzzles/PuzzleLog.tsx
+++ b/src/components/Puzzles/PuzzleLog.tsx
@@ -1,9 +1,9 @@
import { Dispatch, SetStateAction } from 'react'
-import { TrainingGame } from 'src/types/training'
+import { PuzzleGame } from 'src/types/puzzle'
interface Props {
- previousGameResults: (TrainingGame & {
+ previousGameResults: (PuzzleGame & {
result?: boolean
ratingDiff?: number
})[]
diff --git a/src/components/Settings/MaiaModelSettings.tsx b/src/components/Settings/MaiaModelSettings.tsx
index 28c847bc..82a47be9 100644
--- a/src/components/Settings/MaiaModelSettings.tsx
+++ b/src/components/Settings/MaiaModelSettings.tsx
@@ -1,6 +1,6 @@
import React, { useContext, useEffect, useState } from 'react'
import { MaiaEngineContext } from 'src/contexts'
-import { MaiaModelStorage } from 'src/providers/MaiaEngineContextProvider/storage'
+import { MaiaModelStorage } from 'src/lib/engine/storage'
interface StorageInfo {
supported: boolean
diff --git a/src/components/Settings/SoundSettings.tsx b/src/components/Settings/SoundSettings.tsx
index 8c0ef811..cf38c403 100644
--- a/src/components/Settings/SoundSettings.tsx
+++ b/src/components/Settings/SoundSettings.tsx
@@ -1,6 +1,6 @@
import React from 'react'
import { useSettings } from 'src/contexts/SettingsContext'
-import { useChessSoundManager } from 'src/lib/chessSoundManager'
+import { useChessSoundManager } from 'src/lib/sound'
export const SoundSettings: React.FC = () => {
const { settings, updateSetting } = useSettings()
diff --git a/src/lib/openings/openings.json b/src/constants/openings.json
similarity index 100%
rename from src/lib/openings/openings.json
rename to src/constants/openings.json
diff --git a/src/contexts/AnalysisListContext.tsx b/src/contexts/AnalysisListContext.tsx
new file mode 100644
index 00000000..8996fe2d
--- /dev/null
+++ b/src/contexts/AnalysisListContext.tsx
@@ -0,0 +1,157 @@
+import { useRouter } from 'next/router'
+import { AuthContext } from 'src/contexts'
+import React, { ReactNode, useContext, useEffect, useState } from 'react'
+import { MaiaGameListEntry, WorldChampionshipGameListEntry } from 'src/types'
+import {
+ fetchWorldChampionshipGameList,
+ streamLichessGames,
+ fetchMaiaGameList,
+} from 'src/api'
+
+interface IAnalysisListContext {
+ analysisTournamentList: Map | null
+ analysisLichessList: MaiaGameListEntry[]
+ analysisPlayList: MaiaGameListEntry[]
+ analysisHandList: MaiaGameListEntry[]
+ analysisBrainList: MaiaGameListEntry[]
+ analysisCustomList: MaiaGameListEntry[]
+}
+
+export const AnalysisListContext = React.createContext({
+ analysisTournamentList: null,
+ analysisLichessList: [],
+ analysisPlayList: [],
+ analysisHandList: [],
+ analysisBrainList: [],
+ analysisCustomList: [],
+})
+
+export const AnalysisListContextProvider: React.FC<{ children: ReactNode }> = ({
+ children,
+}: {
+ children: ReactNode
+}) => {
+ const router = useRouter()
+ const { user } = useContext(AuthContext)
+
+ const [analysisTournamentList, setAnalysisTournamentList] = useState | null>(null)
+ const [analysisLichessList, setAnalysisLichessList] = useState<
+ MaiaGameListEntry[]
+ >([])
+ const [analysisPlayList, setAnalysisPlayList] = useState(
+ [],
+ )
+ const [analysisHandList, setAnalysisHandList] = useState(
+ [],
+ )
+ const [analysisBrainList, setAnalysisBrainList] = useState<
+ MaiaGameListEntry[]
+ >([])
+ const [analysisCustomList, setAnalysisCustomList] = useState<
+ MaiaGameListEntry[]
+ >([])
+
+ useEffect(() => {
+ async function getAndSetData() {
+ let response
+ try {
+ response = await fetchWorldChampionshipGameList()
+ } catch (e) {
+ router.push('/401')
+ return
+ }
+
+ const newList = new Map(Object.entries(response))
+ setAnalysisTournamentList(newList)
+ }
+
+ getAndSetData()
+ }, [router])
+
+ useEffect(() => {
+ if (user?.lichessId) {
+ streamLichessGames(user?.lichessId, (data) => {
+ const result = data.pgn.match(/\[Result\s+"(.+?)"\]/)[1] || '?'
+
+ const game: MaiaGameListEntry = {
+ id: data.id,
+ type: 'lichess',
+ label: `${data.players.white.user?.id || 'Unknown'} vs. ${data.players.black.user?.id || 'Unknown'}`,
+ result: result,
+ pgn: data.pgn,
+ }
+
+ setAnalysisLichessList((x) => [...x, game])
+ })
+ }
+ }, [user?.lichessId])
+
+ useEffect(() => {
+ if (user?.lichessId) {
+ const playRequest = fetchMaiaGameList('play', 1)
+ const handRequest = fetchMaiaGameList('hand', 1)
+ const brainRequest = fetchMaiaGameList('brain', 1)
+ const customRequest = fetchMaiaGameList('custom', 1)
+
+ Promise.all([playRequest, handRequest, brainRequest, customRequest]).then(
+ (data) => {
+ const [play, hand, brain, custom] = data
+
+ const parse = (
+ game: {
+ game_id: string
+ maia_name: string
+ result: string
+ player_color: 'white' | 'black'
+ },
+ type: 'play' | 'hand' | 'brain' | 'custom',
+ ) => {
+ const raw = game.maia_name.replace('_kdd_', ' ')
+ const maia = raw.charAt(0).toUpperCase() + raw.slice(1)
+
+ return {
+ id: game.game_id,
+ label:
+ game.player_color === 'white'
+ ? `You vs. ${maia}`
+ : `${maia} vs. You`,
+ result: game.result,
+ type,
+ }
+ }
+
+ setAnalysisPlayList(
+ play.games.map((game: never) => parse(game, 'play')),
+ )
+ setAnalysisHandList(
+ hand.games.map((game: never) => parse(game, 'hand')),
+ )
+ setAnalysisBrainList(
+ brain.games.map((game: never) => parse(game, 'brain')),
+ )
+ setAnalysisCustomList(
+ custom.games.map((game: never) => parse(game, 'custom')),
+ )
+ },
+ )
+ }
+ }, [user?.lichessId])
+
+ return (
+
+ {children}
+
+ )
+}
diff --git a/src/contexts/AnalysisListContext/AnalysisListContext.tsx b/src/contexts/AnalysisListContext/AnalysisListContext.tsx
deleted file mode 100644
index 4bc4aadd..00000000
--- a/src/contexts/AnalysisListContext/AnalysisListContext.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import React from 'react'
-
-import { AnalysisWebGame, AnalysisTournamentGame } from 'src/types'
-
-interface IAnalysisListContext {
- analysisTournamentList: Map | null
- analysisLichessList: AnalysisWebGame[]
- analysisPlayList: AnalysisWebGame[]
- analysisHandList: AnalysisWebGame[]
- analysisBrainList: AnalysisWebGame[]
-}
-
-export const AnalysisListContext = React.createContext({
- analysisTournamentList: null,
- analysisLichessList: [],
- analysisPlayList: [],
- analysisHandList: [],
- analysisBrainList: [],
-})
diff --git a/src/contexts/AnalysisListContext/index.ts b/src/contexts/AnalysisListContext/index.ts
deleted file mode 100644
index fbb04829..00000000
--- a/src/contexts/AnalysisListContext/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from './AnalysisListContext'
diff --git a/src/providers/AuthContextProvider/AuthContextProvider.tsx b/src/contexts/AuthContext.tsx
similarity index 53%
rename from src/providers/AuthContextProvider/AuthContextProvider.tsx
rename to src/contexts/AuthContext.tsx
index 61d19df6..2db9e8a4 100644
--- a/src/providers/AuthContextProvider/AuthContextProvider.tsx
+++ b/src/contexts/AuthContext.tsx
@@ -1,8 +1,22 @@
-import { ReactNode, useCallback, useEffect, useState } from 'react'
-import { getAccount, connectLichessUrl, logoutAndGetAccount } from 'src/api'
-import {} from 'src/components'
-import { AuthContext } from 'src/contexts'
import { User } from 'src/types'
+import React, { ReactNode, useCallback, useEffect, useState } from 'react'
+import { fetchAccount, connectLichessUrl, logoutAndFetchAccount } from 'src/api'
+
+interface IAuthContext {
+ user: User | null
+ connectLichess: () => void
+ logout: () => Promise
+}
+
+export const AuthContext = React.createContext({
+ user: null,
+ connectLichess: () => {
+ throw new Error('poorly provided AuthContext, missing connectLichess')
+ },
+ logout: () => {
+ throw new Error('poorly provided AuthContext, missing logout')
+ },
+})
export const AuthContextProvider: React.FC<{ children: ReactNode }> = ({
children,
@@ -13,7 +27,7 @@ export const AuthContextProvider: React.FC<{ children: ReactNode }> = ({
useEffect(() => {
async function getAndSetAccount() {
- const response = await getAccount()
+ const response = await fetchAccount()
setUser(response)
}
@@ -29,7 +43,7 @@ export const AuthContextProvider: React.FC<{ children: ReactNode }> = ({
}, [])
const logout = useCallback(async () => {
- const response = await logoutAndGetAccount()
+ const response = await logoutAndFetchAccount()
setUser(response)
}, [])
diff --git a/src/contexts/AuthContext/AuthContext.ts b/src/contexts/AuthContext/AuthContext.ts
deleted file mode 100644
index 36754292..00000000
--- a/src/contexts/AuthContext/AuthContext.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import React from 'react'
-import { User } from 'src/types'
-
-interface IAuthContext {
- user: User | null
- connectLichess: () => void
- logout: () => Promise
-}
-
-export const AuthContext = React.createContext({
- user: null,
- connectLichess: () => {
- throw new Error('poorly provided AuthContext, missing connectLichess')
- },
- logout: () => {
- throw new Error('poorly provided AuthContext, missing logout')
- },
-})
diff --git a/src/contexts/AuthContext/index.ts b/src/contexts/AuthContext/index.ts
deleted file mode 100644
index 73854db0..00000000
--- a/src/contexts/AuthContext/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from './AuthContext'
diff --git a/src/contexts/BaseTreeControllerContext.ts b/src/contexts/BaseTreeControllerContext.ts
deleted file mode 100644
index de094be8..00000000
--- a/src/contexts/BaseTreeControllerContext.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { GameTree, GameNode } from 'src/types'
-
-export interface BaseTreeControllerContext {
- gameTree: GameTree
- currentNode: GameNode
- goToNode: (node: GameNode) => void
- goToNextNode: () => void
- goToPreviousNode: () => void
- goToRootNode: () => void
- plyCount: number
- orientation: 'white' | 'black'
- setOrientation: (orientation: 'white' | 'black') => void
-}
diff --git a/src/providers/MaiaEngineContextProvider/MaiaEngineContextProvider.tsx b/src/contexts/MaiaEngineContext.tsx
similarity index 86%
rename from src/providers/MaiaEngineContextProvider/MaiaEngineContextProvider.tsx
rename to src/contexts/MaiaEngineContext.tsx
index c94baa62..c05cd2f2 100644
--- a/src/providers/MaiaEngineContextProvider/MaiaEngineContextProvider.tsx
+++ b/src/contexts/MaiaEngineContext.tsx
@@ -1,7 +1,6 @@
-import Maia from './model'
-import { MaiaStatus } from 'src/types'
-import { MaiaEngineContext } from 'src/contexts'
-import {
+import Maia from '../lib/engine/maia'
+import { MaiaStatus, MaiaEngine } from 'src/types'
+import React, {
ReactNode,
useState,
useMemo,
@@ -11,6 +10,15 @@ import {
} from 'react'
import toast from 'react-hot-toast'
+export const MaiaEngineContext = React.createContext({
+ maia: undefined,
+ status: 'loading',
+ progress: 0,
+ downloadModel: async () => {
+ throw new Error('poorly provided MaiaEngineContext, missing downloadModel')
+ },
+})
+
export const MaiaEngineContextProvider: React.FC<{ children: ReactNode }> = ({
children,
}: {
diff --git a/src/contexts/MaiaEngineContext/MaiaEngineContext.ts b/src/contexts/MaiaEngineContext/MaiaEngineContext.ts
deleted file mode 100644
index 5ff37d0d..00000000
--- a/src/contexts/MaiaEngineContext/MaiaEngineContext.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import React from 'react'
-import { MaiaEngine } from 'src/types'
-
-export const MaiaEngineContext = React.createContext({
- maia: undefined,
- status: 'loading',
- progress: 0,
- downloadModel: async () => {
- throw new Error('poorly provided MaiaEngineContext, missing downloadModel')
- },
- // getStorageInfo: async () => {
- // throw new Error('poorly provided MaiaEngineContext, missing getStorageInfo')
- // },
- // clearStorage: async () => {
- // throw new Error('poorly provided MaiaEngineContext, missing clearStorage')
- // },
-})
diff --git a/src/contexts/MaiaEngineContext/index.ts b/src/contexts/MaiaEngineContext/index.ts
deleted file mode 100644
index d585f0c6..00000000
--- a/src/contexts/MaiaEngineContext/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { MaiaEngineContext } from './MaiaEngineContext'
diff --git a/src/providers/ModalContextProvider/ModalContextProvider.tsx b/src/contexts/ModalContext.tsx
similarity index 57%
rename from src/providers/ModalContextProvider/ModalContextProvider.tsx
rename to src/contexts/ModalContext.tsx
index 0f953127..4e910297 100644
--- a/src/providers/ModalContextProvider/ModalContextProvider.tsx
+++ b/src/contexts/ModalContext.tsx
@@ -1,7 +1,18 @@
-import { ComponentProps, ReactNode, useState } from 'react'
-
-import { ModalContext } from 'src/contexts'
import { PlaySetupModal } from 'src/components'
+import React, { ComponentProps, ReactNode, useState } from 'react'
+
+const fn = () => {
+ throw new Error('poorly provided ModalContext')
+}
+
+interface IModalContext {
+ playSetupModalProps?: ComponentProps
+ setPlaySetupModalProps: (arg0?: ComponentProps) => void
+}
+
+export const ModalContext = React.createContext({
+ setPlaySetupModalProps: fn,
+})
export const ModalContextProvider: React.FC<{ children: ReactNode }> = ({
children,
diff --git a/src/contexts/ModalContext/ModalContext.ts b/src/contexts/ModalContext/ModalContext.ts
deleted file mode 100644
index 2b917f85..00000000
--- a/src/contexts/ModalContext/ModalContext.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import React, { ComponentProps } from 'react'
-
-import { Modals } from 'src/types'
-import { PlaySetupModal } from 'src/components'
-
-const fn = () => {
- throw new Error('poorly provided ModalContext')
-}
-
-interface IModalContext {
- playSetupModalProps?: ComponentProps
- setPlaySetupModalProps: (arg0?: ComponentProps) => void
-}
-
-export const ModalContext = React.createContext({
- setPlaySetupModalProps: fn,
-})
diff --git a/src/contexts/ModalContext/index.ts b/src/contexts/ModalContext/index.ts
deleted file mode 100644
index 420e80fa..00000000
--- a/src/contexts/ModalContext/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from './ModalContext'
diff --git a/src/contexts/PlayControllerContext/PlayControllerContext.ts b/src/contexts/PlayControllerContext.ts
similarity index 90%
rename from src/contexts/PlayControllerContext/PlayControllerContext.ts
rename to src/contexts/PlayControllerContext.ts
index 07f79bbf..a9ed3145 100644
--- a/src/contexts/PlayControllerContext/PlayControllerContext.ts
+++ b/src/contexts/PlayControllerContext.ts
@@ -1,8 +1,7 @@
import React from 'react'
import { Chess } from 'chess.ts'
-import { GameTree } from 'src/types'
+import { GameTree, BaseTreeControllerContext } from 'src/types'
import { usePlayController } from 'src/hooks/usePlayController'
-import { BaseTreeControllerContext } from '../BaseTreeControllerContext'
export interface IPlayControllerContext extends BaseTreeControllerContext {
game: ReturnType['game']
@@ -25,7 +24,6 @@ export interface IPlayControllerContext extends BaseTreeControllerContext {
makePlayerMove: ReturnType['makePlayerMove']
updateClock: ReturnType['updateClock']
setCurrentNode: ReturnType['setCurrentNode']
- addMove: ReturnType['addMove']
addMoveWithTime: ReturnType['addMoveWithTime']
}
@@ -37,7 +35,7 @@ const defaultGameTree = new GameTree(new Chess().fen())
export const PlayControllerContext =
React.createContext({
- game: { id: '', moves: [], turn: 'black', tree: defaultGameTree },
+ game: { id: '', turn: 'black', tree: defaultGameTree },
playType: 'againstMaia',
timeControl: 'unlimited',
player: 'white',
@@ -71,6 +69,5 @@ export const PlayControllerContext =
goToNextNode: fn,
goToPreviousNode: fn,
goToRootNode: fn,
- addMove: fn,
addMoveWithTime: fn,
})
diff --git a/src/contexts/SettingsContext/SettingsContext.tsx b/src/contexts/SettingsContext.tsx
similarity index 100%
rename from src/contexts/SettingsContext/SettingsContext.tsx
rename to src/contexts/SettingsContext.tsx
diff --git a/src/contexts/SettingsContext/index.ts b/src/contexts/SettingsContext/index.ts
deleted file mode 100644
index 8e5a81a3..00000000
--- a/src/contexts/SettingsContext/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { SettingsProvider, useSettings } from './SettingsContext'
diff --git a/src/providers/StockfishEngineContextProvider/StockfishEngineContextProvider.tsx b/src/contexts/StockfishEngineContext.tsx
similarity index 81%
rename from src/providers/StockfishEngineContextProvider/StockfishEngineContextProvider.tsx
rename to src/contexts/StockfishEngineContext.tsx
index 0cf048ba..06d912c5 100644
--- a/src/providers/StockfishEngineContextProvider/StockfishEngineContextProvider.tsx
+++ b/src/contexts/StockfishEngineContext.tsx
@@ -1,4 +1,4 @@
-import {
+import React, {
ReactNode,
useRef,
useCallback,
@@ -7,9 +7,26 @@ import {
useMemo,
} from 'react'
import toast from 'react-hot-toast'
-import { StockfishEngineContext } from 'src/contexts'
-import { StockfishStatus } from 'src/types'
-import Engine from 'src/providers/StockfishEngineContextProvider/engine'
+import { StockfishStatus, StockfishEngine } from 'src/types'
+import Engine from 'src/lib/engine/stockfish'
+
+export const StockfishEngineContext = React.createContext({
+ streamEvaluations: () => {
+ throw new Error(
+ 'poorly provided StockfishEngineContext, missing streamEvaluations',
+ )
+ },
+ stopEvaluation: () => {
+ throw new Error(
+ 'poorly provided StockfishEngineContext, missing stopEvaluation',
+ )
+ },
+ isReady: () => {
+ throw new Error('poorly provided StockfishEngineContext, missing isReady')
+ },
+ status: 'loading',
+ error: null,
+})
export const StockfishEngineContextProvider: React.FC<{
children: ReactNode
diff --git a/src/contexts/StockfishEngineContext/StockfishEngineContext.ts b/src/contexts/StockfishEngineContext/StockfishEngineContext.ts
deleted file mode 100644
index 8a3d4965..00000000
--- a/src/contexts/StockfishEngineContext/StockfishEngineContext.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import React from 'react'
-import { StockfishEngine } from 'src/types'
-
-export const StockfishEngineContext = React.createContext({
- streamEvaluations: () => {
- throw new Error(
- 'poorly provided StockfishEngineContext, missing streamEvaluations',
- )
- },
- stopEvaluation: () => {
- throw new Error(
- 'poorly provided StockfishEngineContext, missing stopEvaluation',
- )
- },
- isReady: () => {
- throw new Error('poorly provided StockfishEngineContext, missing isReady')
- },
- status: 'loading',
- error: null,
-})
diff --git a/src/contexts/StockfishEngineContext/index.ts b/src/contexts/StockfishEngineContext/index.ts
deleted file mode 100644
index a14235cf..00000000
--- a/src/contexts/StockfishEngineContext/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { StockfishEngineContext } from './StockfishEngineContext'
diff --git a/src/contexts/TourContext/TourContext.tsx b/src/contexts/TourContext.tsx
similarity index 100%
rename from src/contexts/TourContext/TourContext.tsx
rename to src/contexts/TourContext.tsx
diff --git a/src/contexts/TourContext/index.ts b/src/contexts/TourContext/index.ts
deleted file mode 100644
index 89125e89..00000000
--- a/src/contexts/TourContext/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from './TourContext'
diff --git a/src/providers/TourProvider/TourProvider.tsx b/src/contexts/TourProvider.tsx
similarity index 91%
rename from src/providers/TourProvider/TourProvider.tsx
rename to src/contexts/TourProvider.tsx
index 6c48c866..34418fb1 100644
--- a/src/providers/TourProvider/TourProvider.tsx
+++ b/src/contexts/TourProvider.tsx
@@ -1,5 +1,5 @@
import { ReactNode } from 'react'
-import { TourProvider as TourContextProvider } from 'src/contexts/TourContext/TourContext'
+import { TourProvider as TourContextProvider } from 'src/contexts/TourContext'
export const TourProvider: React.FC<{ children: ReactNode }> = ({
children,
diff --git a/src/contexts/TrainingControllerContext/TrainingControllerContext.ts b/src/contexts/TrainingControllerContext.ts
similarity index 86%
rename from src/contexts/TrainingControllerContext/TrainingControllerContext.ts
rename to src/contexts/TrainingControllerContext.ts
index b733b013..1b5fa112 100644
--- a/src/contexts/TrainingControllerContext/TrainingControllerContext.ts
+++ b/src/contexts/TrainingControllerContext.ts
@@ -1,7 +1,6 @@
import { Chess } from 'chess.ts'
import { createContext } from 'react'
-import { GameTree, GameNode } from 'src/types'
-import { BaseTreeControllerContext } from '../BaseTreeControllerContext'
+import { GameTree, GameNode, BaseTreeControllerContext } from 'src/types'
export interface ITrainingControllerContext extends BaseTreeControllerContext {
currentNode: GameNode
@@ -11,6 +10,9 @@ export interface ITrainingControllerContext extends BaseTreeControllerContext {
const defaultContext: ITrainingControllerContext = {
gameTree: new GameTree(new Chess().fen()),
currentNode: new GameTree(new Chess().fen()).getRoot(),
+ setCurrentNode: () => {
+ /* no-op */
+ },
goToNode: () => {
/* no-op */
},
diff --git a/src/contexts/TrainingControllerContext/index.ts b/src/contexts/TrainingControllerContext/index.ts
deleted file mode 100644
index 1d6aa677..00000000
--- a/src/contexts/TrainingControllerContext/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from './TrainingControllerContext'
diff --git a/src/contexts/TreeControllerContext/TreeControllerContext.ts b/src/contexts/TreeControllerContext.ts
similarity index 62%
rename from src/contexts/TreeControllerContext/TreeControllerContext.ts
rename to src/contexts/TreeControllerContext.ts
index 188f083d..61c713ff 100644
--- a/src/contexts/TreeControllerContext/TreeControllerContext.ts
+++ b/src/contexts/TreeControllerContext.ts
@@ -1,18 +1,11 @@
-import React, { SetStateAction } from 'react'
+import React from 'react'
import { Chess } from 'chess.ts'
-import { useTreeController } from 'src/hooks/useTreeController'
-import { BaseTreeControllerContext } from '../BaseTreeControllerContext'
-import { GameTree } from 'src/types'
-
-export interface ITreeControllerContext extends BaseTreeControllerContext {
- currentNode: ReturnType['currentNode']
- setCurrentNode: ReturnType['setCurrentNode']
-}
+import { GameTree, BaseTreeControllerContext } from 'src/types'
const defaultGameTree = new GameTree(new Chess().fen())
export const TreeControllerContext =
- React.createContext({
+ React.createContext({
gameTree: defaultGameTree,
currentNode: defaultGameTree.getRoot(),
setCurrentNode: () => {
diff --git a/src/contexts/TreeControllerContext/index.ts b/src/contexts/TreeControllerContext/index.ts
deleted file mode 100644
index 5f52b7cc..00000000
--- a/src/contexts/TreeControllerContext/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from './TreeControllerContext'
diff --git a/src/contexts/TuringTreeControllerContext/TuringTreeControllerContext.ts b/src/contexts/TuringTreeControllerContext.ts
similarity index 92%
rename from src/contexts/TuringTreeControllerContext/TuringTreeControllerContext.ts
rename to src/contexts/TuringTreeControllerContext.ts
index e04b70c4..8772fb45 100644
--- a/src/contexts/TuringTreeControllerContext/TuringTreeControllerContext.ts
+++ b/src/contexts/TuringTreeControllerContext.ts
@@ -1,9 +1,8 @@
import { Chess } from 'chess.ts'
import { createContext } from 'react'
-import { GameTree, Color, GameNode } from 'src/types'
import { TuringGame } from 'src/types/turing'
import { AllStats } from 'src/hooks/useStats'
-import { BaseTreeControllerContext } from '../BaseTreeControllerContext'
+import { GameTree, Color, BaseTreeControllerContext } from 'src/types'
export interface ITuringControllerContext extends BaseTreeControllerContext {
game?: TuringGame
@@ -26,6 +25,9 @@ export interface ITuringControllerContext extends BaseTreeControllerContext {
const defaultContext: ITuringControllerContext = {
gameTree: new GameTree(new Chess().fen()),
currentNode: new GameTree(new Chess().fen()).getRoot(),
+ setCurrentNode: () => {
+ /* no-op */
+ },
goToNode: () => {
/* no-op */
},
diff --git a/src/contexts/TuringTreeControllerContext/index.ts b/src/contexts/TuringTreeControllerContext/index.ts
deleted file mode 100644
index ff7e36eb..00000000
--- a/src/contexts/TuringTreeControllerContext/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from './TuringTreeControllerContext'
diff --git a/src/providers/WindowSizeContextProvider/WindowSizeContextProvider.tsx b/src/contexts/WindowSizeContext.tsx
similarity index 61%
rename from src/providers/WindowSizeContextProvider/WindowSizeContextProvider.tsx
rename to src/contexts/WindowSizeContext.tsx
index 94d23d05..e3537d85 100644
--- a/src/providers/WindowSizeContextProvider/WindowSizeContextProvider.tsx
+++ b/src/contexts/WindowSizeContext.tsx
@@ -1,6 +1,17 @@
-import { ReactNode, useMemo } from 'react'
-import { WindowSizeContext } from 'src/contexts'
import { useWindowSize } from 'src/hooks'
+import React, { ReactNode, useMemo } from 'react'
+
+interface IWindowSizeContext {
+ height: number
+ width: number
+ isMobile: boolean
+}
+
+export const WindowSizeContext = React.createContext({
+ height: 0,
+ width: 0,
+ isMobile: false,
+})
export const WindowSizeContextProvider: React.FC<{ children: ReactNode }> = ({
children,
diff --git a/src/contexts/WindowSizeContext/WindowSizeContext.ts b/src/contexts/WindowSizeContext/WindowSizeContext.ts
deleted file mode 100644
index ef1db965..00000000
--- a/src/contexts/WindowSizeContext/WindowSizeContext.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import React from 'react'
-
-interface IWindowSizeContext {
- height: number
- width: number
- isMobile: boolean
-}
-
-export const WindowSizeContext = React.createContext({
- height: 0,
- width: 0,
- isMobile: false,
-})
diff --git a/src/contexts/WindowSizeContext/index.ts b/src/contexts/WindowSizeContext/index.ts
deleted file mode 100644
index 7d844eb8..00000000
--- a/src/contexts/WindowSizeContext/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from './WindowSizeContext'
diff --git a/src/contexts/index.ts b/src/contexts/index.ts
index 39acca94..56872a41 100644
--- a/src/contexts/index.ts
+++ b/src/contexts/index.ts
@@ -1,11 +1,12 @@
-export * from './WindowSizeContext'
-export * from './ModalContext'
-export * from './TreeControllerContext'
-export * from './AuthContext'
-export * from './TuringTreeControllerContext'
export * from './AnalysisListContext'
-export * from './BaseTreeControllerContext'
-export * from './TourContext'
+export * from './AuthContext'
export * from './MaiaEngineContext'
-export * from './StockfishEngineContext'
+export * from './ModalContext'
+export * from './PlayControllerContext'
export * from './SettingsContext'
+export * from './StockfishEngineContext'
+export * from './TourContext'
+export * from './TrainingControllerContext'
+export * from './TreeControllerContext'
+export * from './TuringTreeControllerContext'
+export * from './WindowSizeContext'
diff --git a/src/hooks/index.ts b/src/hooks/index.ts
index 11ac3dbb..e37c092f 100644
--- a/src/hooks/index.ts
+++ b/src/hooks/index.ts
@@ -1,5 +1,4 @@
export * from './useAnalysisController'
-export * from './useBaseTreeController'
export * from './useChessSound'
export * from './useLocalStorage'
export * from './useOpeningDrillController'
diff --git a/src/hooks/useAnalysisController/useAnalysisController.ts b/src/hooks/useAnalysisController/useAnalysisController.ts
index c6ebb00a..cc1f2241 100644
--- a/src/hooks/useAnalysisController/useAnalysisController.ts
+++ b/src/hooks/useAnalysisController/useAnalysisController.ts
@@ -20,12 +20,13 @@ import { useMoveRecommendations } from './useMoveRecommendations'
import { MaiaEngineContext } from 'src/contexts/MaiaEngineContext'
import { generateColorSanMapping, calculateBlunderMeter } from './utils'
import { StockfishEngineContext } from 'src/contexts/StockfishEngineContext'
+import { storeGameAnalysisCache } from 'src/api/analysis'
import {
+ extractPlayerMistakes,
+ isBestMove,
collectEngineAnalysisData,
generateAnalysisCacheKey,
-} from 'src/lib/analysisStorage'
-import { storeEngineAnalysis } from 'src/api/analysis/analysis'
-import { extractPlayerMistakes, isBestMove } from 'src/lib/analysis'
+} from 'src/lib/analysis'
import { LearnFromMistakesState, MistakePosition } from 'src/types/analysis'
import { LEARN_FROM_MISTAKES_DEPTH } from 'src/constants/analysis'
@@ -90,13 +91,7 @@ export const useAnalysisController = (
const autoSaveTimerRef = useRef(null)
const saveAnalysisToBackend = useCallback(async () => {
- if (
- !enableAutoSave ||
- !game.id ||
- game.type === 'custom-pgn' ||
- game.type === 'custom-fen' ||
- game.type === 'tournament'
- ) {
+ if (!enableAutoSave || !game.id || game.type === 'tournament') {
return
}
@@ -129,7 +124,7 @@ export const useAnalysisController = (
return
}
- await storeEngineAnalysis(game.id, analysisData)
+ await storeGameAnalysisCache(game.id, analysisData)
setLastSavedCacheKey(cacheKey)
setHasUnsavedAnalysis(false) // Mark as saved
console.log(
@@ -480,7 +475,7 @@ export const useAnalysisController = (
const moveResult = chess.move(currentMistake.bestMove, { sloppy: true })
if (moveResult) {
- const newVariation = game.tree.addVariation(
+ const newVariation = game.tree.addVariationNode(
controller.currentNode,
chess.fen(),
currentMistake.bestMove,
diff --git a/src/hooks/useAnalysisController/useDescriptionGenerator.ts b/src/hooks/useAnalysisController/useDescriptionGenerator.ts
index 4d13286f..f4d8a40c 100644
--- a/src/hooks/useAnalysisController/useDescriptionGenerator.ts
+++ b/src/hooks/useAnalysisController/useDescriptionGenerator.ts
@@ -1,5 +1,5 @@
+import { cpToWinrate } from 'src/lib'
import { Chess, PieceSymbol } from 'chess.ts'
-import { cpToWinrate } from 'src/lib/stockfish'
type StockfishEvals = Record
type MaiaEvals = Record
diff --git a/src/hooks/useAnalysisController/useEngineAnalysis.ts b/src/hooks/useAnalysisController/useEngineAnalysis.ts
index 370dcbee..582354b5 100644
--- a/src/hooks/useAnalysisController/useEngineAnalysis.ts
+++ b/src/hooks/useAnalysisController/useEngineAnalysis.ts
@@ -1,5 +1,5 @@
import { Chess } from 'chess.ts'
-import { getBookMoves } from 'src/api'
+import { fetchOpeningBookMoves } from 'src/api'
import { useEffect, useContext } from 'react'
import { MAIA_MODELS } from 'src/constants/common'
import { GameNode, MaiaEvaluation } from 'src/types'
@@ -37,7 +37,7 @@ export const useEngineAnalysis = (
}
async function fetchOpeningBook(board: Chess) {
- const bookMoves = await getBookMoves(board.fen())
+ const bookMoves = await fetchOpeningBookMoves(board.fen())
return bookMoves
}
diff --git a/src/hooks/useBaseTreeController.ts b/src/hooks/useBaseTreeController.ts
deleted file mode 100644
index 8ac78cd9..00000000
--- a/src/hooks/useBaseTreeController.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { useContext } from 'react'
-import { BaseTreeControllerContext } from 'src/contexts/BaseTreeControllerContext'
-import { TreeControllerContext } from 'src/contexts/TreeControllerContext/TreeControllerContext'
-import { PlayControllerContext } from 'src/contexts/PlayControllerContext/PlayControllerContext'
-import { TuringControllerContext } from 'src/contexts/TuringTreeControllerContext/TuringTreeControllerContext'
-import { TrainingControllerContext } from 'src/contexts/TrainingControllerContext/TrainingControllerContext'
-
-type ContextType = 'analysis' | 'play' | 'turing' | 'training'
-
-export function useBaseTreeController(
- type: ContextType,
-): BaseTreeControllerContext {
- const analysisContext = useContext(TreeControllerContext)
- const playContext = useContext(PlayControllerContext)
- const turingContext = useContext(TuringControllerContext)
- const trainingContext = useContext(TrainingControllerContext)
-
- switch (type) {
- case 'analysis':
- return analysisContext
- case 'play':
- return playContext
- case 'turing':
- return turingContext
- case 'training':
- return trainingContext
- default:
- throw new Error(`Unknown context type: ${type}`)
- }
-}
diff --git a/src/hooks/useChessSound.ts b/src/hooks/useChessSound.ts
index daaebd4d..71f5d668 100644
--- a/src/hooks/useChessSound.ts
+++ b/src/hooks/useChessSound.ts
@@ -1,4 +1,4 @@
-import { useChessSoundManager } from 'src/lib/chessSoundManager'
+import { useChessSoundManager } from 'src/lib/sound'
/**
* @deprecated Use useChessSoundManager directly instead
diff --git a/src/hooks/useLeaderboardStatus.ts b/src/hooks/useLeaderboardStatus.ts
index 5d2228f4..e4dca01b 100644
--- a/src/hooks/useLeaderboardStatus.ts
+++ b/src/hooks/useLeaderboardStatus.ts
@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useRef } from 'react'
-import { getLeaderboard } from 'src/api'
+import { fetchLeaderboard } from 'src/api'
import {
LeaderboardData,
LeaderboardStatus,
@@ -104,7 +104,7 @@ export const useLeaderboardStatus = (displayName?: string) => {
}
// Fetch fresh data
- const leaderboardData = await getLeaderboard()
+ const leaderboardData = await fetchLeaderboard()
// Update cache
cacheRef.current = {
diff --git a/src/hooks/useLichessStreamController.ts b/src/hooks/useLichessStreamController.ts
index 27eae08d..b42e79ce 100644
--- a/src/hooks/useLichessStreamController.ts
+++ b/src/hooks/useLichessStreamController.ts
@@ -6,11 +6,11 @@ import {
StreamedMove,
StreamedGame,
} from 'src/types'
+import { streamLichessGameMoves } from 'src/api/lichess'
import {
- streamLichessGame,
- createAnalyzedGameFromLichessStream,
- parseLichessStreamMove,
-} from 'src/api/lichess/streaming'
+ handleLichessStreamMove,
+ convertLichessStreamToLiveGame,
+} from 'src/lib'
export interface LichessStreamController {
game: LiveGame | undefined
@@ -109,7 +109,7 @@ export const useLichessStreamController = (): LichessStreamController => {
}
}
- return createAnalyzedGameFromLichessStream(gameData)
+ return convertLichessStreamToLiveGame(gameData)
})
setStreamState((prev) => ({
@@ -153,7 +153,7 @@ export const useLichessStreamController = (): LichessStreamController => {
}
try {
- const newGame = parseLichessStreamMove(moveData, prev)
+ const newGame = handleLichessStreamMove(moveData, prev)
return {
...newGame,
@@ -201,7 +201,7 @@ export const useLichessStreamController = (): LichessStreamController => {
try {
// Start streaming directly - the stream API will handle invalid game IDs
// Note: isConnected will be set when we receive the first data (in handleStreamedGameInfo or handleMove)
- await streamLichessGame(
+ await streamLichessGameMoves(
gameId,
handleStreamedGameInfo,
handleMove,
diff --git a/src/hooks/useOpeningDrillController/useOpeningDrillController.ts b/src/hooks/useOpeningDrillController/useOpeningDrillController.ts
index 06c1be49..e37c9fb2 100644
--- a/src/hooks/useOpeningDrillController/useOpeningDrillController.ts
+++ b/src/hooks/useOpeningDrillController/useOpeningDrillController.ts
@@ -1,7 +1,7 @@
import { useState, useMemo, useCallback, useEffect, useRef } from 'react'
import { Chess } from 'chess.ts'
-import { getGameMove } from 'src/api/play/play'
-import { submitOpeningDrill } from 'src/api/opening'
+import { fetchGameMove } from 'src/api/play'
+import { submitOpeningDrill } from 'src/api/openings'
import { useTreeController } from '../useTreeController'
import { useLocalStorage } from '../useLocalStorage'
import {
@@ -24,7 +24,7 @@ import {
} from 'src/types/openings'
import { MAIA_MODELS } from 'src/constants/common'
import { MIN_STOCKFISH_DEPTH } from 'src/constants/analysis'
-import { chessSoundManager } from 'src/lib/chessSoundManager'
+import { chessSoundManager } from 'src/lib/sound'
interface CachedAnalysisResult {
fen: string
@@ -38,6 +38,7 @@ interface AnalysisProgress {
completed: number
currentMove: string | null
}
+
const parsePgnToTree = (pgn: string, gameTree: GameTree): GameNode | null => {
if (!pgn || pgn.trim() === '') return gameTree.getRoot()
@@ -60,12 +61,9 @@ const parsePgnToTree = (pgn: string, gameTree: GameTree): GameNode | null => {
if (existingChild) {
currentNode = existingChild
} else {
- const newNode = gameTree.addMainMove(
- currentNode,
- chess.fen(),
- moveUci,
- moveObj.san,
- )
+ const newNode = gameTree
+ .getLastMainlineNode()
+ .addChild(chess.fen(), moveUci, moveObj.san)
if (newNode) {
currentNode = newNode
} else {
@@ -809,12 +807,9 @@ export const useOpeningDrillController = (
try {
const moveObj = chess.move(moveUci, { sloppy: true })
if (moveObj) {
- const newNode = gameTree.addMainMove(
- currentNode,
- chess.fen(),
- moveUci,
- moveObj.san,
- )
+ const newNode = gameTree
+ .getLastMainlineNode()
+ .addChild(chess.fen(), moveUci, moveObj.san)
if (newNode) {
currentNode = newNode
finalNode = newNode
@@ -833,12 +828,10 @@ export const useOpeningDrillController = (
try {
const moveObj = chess.move(moveUci, { sloppy: true })
if (moveObj) {
- const newNode = gameTree.addMainMove(
- currentNode,
- chess.fen(),
- moveUci,
- moveObj.san,
- )
+ const newNode = gameTree
+ .getLastMainlineNode()
+ .addChild(currentNode, chess.fen(), moveUci, true, moveObj.san)
+
if (newNode) {
currentNode = newNode
finalNode = newNode
@@ -1157,7 +1150,7 @@ export const useOpeningDrillController = (
if (!currentDrillGame || !currentDrill || !fromNode) return
try {
- const response = await getGameMove(
+ const response = await fetchGameMove(
[],
currentDrill.maiaVersion,
fromNode.fen,
diff --git a/src/hooks/usePlayController/useHandBrainController.ts b/src/hooks/usePlayController/useHandBrainController.ts
index 29b9e3a2..6c4210cb 100644
--- a/src/hooks/usePlayController/useHandBrainController.ts
+++ b/src/hooks/usePlayController/useHandBrainController.ts
@@ -7,12 +7,12 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { PlayGameConfig } from 'src/types'
import { useStats } from 'src/hooks/useStats'
import { usePlayController } from './usePlayController'
-import { getGameMove, submitGameMove, getPlayPlayerStats } from 'src/api'
-import { chessSoundManager } from 'src/lib/chessSoundManager'
+import { fetchGameMove, logGameMove, fetchPlayPlayerStats } from 'src/api'
+import { chessSoundManager } from 'src/lib/sound'
import { safeUpdateRating } from 'src/lib/ratingUtils'
const brainStatsLoader = async () => {
- const stats = await getPlayPlayerStats()
+ const stats = await fetchPlayPlayerStats()
return {
gamesPlayed: stats.brainGamesPlayed,
gamesWon: stats.brainWon,
@@ -21,7 +21,7 @@ const brainStatsLoader = async () => {
}
const handStatsLoader = async () => {
- const stats = await getPlayPlayerStats()
+ const stats = await fetchPlayPlayerStats()
return {
gamesPlayed: stats.handGamesPlayed,
gamesWon: stats.handWon,
@@ -34,8 +34,8 @@ export const useHandBrainController = (
playGameConfig: PlayGameConfig,
simulateMaiaTime: boolean,
) => {
- const controller = usePlayController(id, playGameConfig)
const isBrain = playGameConfig.isBrain
+ const controller = usePlayController(id, playGameConfig)
const [selectedPiece, setSelectedPiece] = useState(
undefined,
@@ -74,7 +74,7 @@ export const useHandBrainController = (
const maiaChoosePiece = async () => {
const maiaMoves = await backOff(
() =>
- getGameMove(
+ fetchGameMove(
controller.moveList,
playGameConfig.maiaPartnerVersion,
playGameConfig.startFen,
@@ -139,7 +139,7 @@ export const useHandBrainController = (
const maiaMoves = await backOff(
() =>
- getGameMove(
+ fetchGameMove(
controller.moveList,
playGameConfig.maiaVersion,
playGameConfig.startFen,
@@ -200,7 +200,7 @@ export const useHandBrainController = (
const maiaMoves = await backOff(
() =>
- getGameMove(
+ fetchGameMove(
controller.moveList,
playGameConfig.maiaPartnerVersion,
playGameConfig.startFen,
@@ -258,7 +258,7 @@ export const useHandBrainController = (
const submitFn = async () => {
const response = await backOff(
() =>
- submitGameMove(
+ logGameMove(
controller.game.id,
controller.moveList,
controller.moveTimes,
diff --git a/src/hooks/usePlayController/usePlayController.ts b/src/hooks/usePlayController/usePlayController.ts
index 5741926e..2cc38192 100644
--- a/src/hooks/usePlayController/usePlayController.ts
+++ b/src/hooks/usePlayController/usePlayController.ts
@@ -1,11 +1,4 @@
-import {
- Color,
- Check,
- GameNode,
- GameTree,
- Termination,
- PlayGameConfig,
-} from 'src/types'
+import { Color, Check, GameTree, Termination, PlayGameConfig } from 'src/types'
import { AllStats } from '../useStats'
import { PlayedGame } from 'src/types/play'
import { Chess, Piece, SQUARES } from 'chess.ts'
@@ -60,10 +53,10 @@ const computeTimeTermination = (
}
export const usePlayController = (id: string, config: PlayGameConfig) => {
- const [gameTree, setGameTree] = useState(
- () => new GameTree(config.startFen || nullFen),
+ const controller = useTreeController(
+ new GameTree(config.startFen || nullFen),
+ config.player,
)
- const controller = useTreeController(gameTree, config.player)
const [treeVersion, setTreeVersion] = useState(0)
const [resigned, setResigned] = useState(false)
@@ -79,32 +72,36 @@ export const usePlayController = (id: string, config: PlayGameConfig) => {
const [lastMoveTime, setLastMoveTime] = useState(0)
const moveList = useMemo(
- () => gameTree.toMoveArray(),
- [gameTree, treeVersion],
+ () => controller.tree.toMoveArray(),
+ [controller.tree, treeVersion],
)
const moveTimes = useMemo(
- () => gameTree.toTimeArray(),
- [gameTree, treeVersion],
+ () => controller.tree.toTimeArray(),
+ [controller.tree, treeVersion],
)
const game: PlayedGame = useMemo(() => {
- const mainLine = gameTree.getMainLine()
+ const mainLine = controller.tree.getMainLine()
+ console.log(mainLine)
const lastNode = mainLine[mainLine.length - 1]
- const chess = gameTree.toChess()
+ const turn = lastNode.turn
+ const chess = controller.tree.toChess()
+
+ console.log('gme node', lastNode.fen)
const termination = resigned
? Math.min(whiteClock, blackClock) > 0
? ({
- result: chess.turn() == 'w' ? '0-1' : '1-0',
- winner: chess.turn() == 'w' ? 'black' : 'white',
+ result: turn == 'w' ? '0-1' : '1-0',
+ winner: turn == 'w' ? 'black' : 'white',
type: 'resign',
} as Termination)
- : computeTimeTermination(chess, chess.turn() == 'w' ? 'white' : 'black')
+ : computeTimeTermination(chess, turn == 'w' ? 'white' : 'black')
: computeTermination(chess)
const moves = []
- const rootNode = gameTree.getRoot()
+ const rootNode = controller.tree.getRoot()
const rootChess = new Chess(rootNode.fen)
moves.push({
board: rootNode.fen,
@@ -131,12 +128,11 @@ export const usePlayController = (id: string, config: PlayGameConfig) => {
return {
id,
- moves,
- tree: gameTree,
termination,
- turn: chess.turn() == 'b' ? 'black' : 'white',
+ tree: controller.tree,
+ turn: turn == 'b' ? 'black' : 'white',
}
- }, [gameTree, treeVersion, resigned, whiteClock, blackClock, id])
+ }, [controller.tree, treeVersion, resigned, whiteClock, blackClock, id])
const toPlay: Color | null = game.termination ? null : game.turn
const playerActive = toPlay == config.player
@@ -228,33 +224,37 @@ export const usePlayController = (id: string, config: PlayGameConfig) => {
whiteClock,
])
- const addMove = useCallback(
- (moveUci: string) => {
- const newNode = gameTree.addMoveToMainLine(moveUci)
- if (newNode) {
- controller.setCurrentNode(newNode)
- // Force re-render by incrementing tree version
- setTreeVersion((prev) => prev + 1)
- }
- },
- [gameTree, controller],
- )
-
const addMoveWithTime = useCallback(
(moveUci: string, moveTime: number) => {
- const newNode = gameTree.addMoveToMainLine(moveUci, moveTime)
- if (newNode) {
- controller.setCurrentNode(newNode)
- // Force re-render by incrementing tree version
- setTreeVersion((prev) => prev + 1)
+ const lastNode = controller.tree.getLastMainlineNode()
+ const board = new Chess(lastNode.fen)
+ const result = board.move(moveUci, { sloppy: true })
+
+ if (result) {
+ const newNode = lastNode.addChild(
+ board.fen(),
+ moveUci,
+ result.san,
+ true,
+ undefined,
+ moveTime,
+ )
+
+ if (newNode) {
+ console.log('old node', controller.currentNode.fen)
+ console.log('new move', moveUci)
+ console.log('new node', newNode.fen)
+ controller.setCurrentNode(newNode)
+ setTreeVersion((prev) => prev + 1)
+ }
}
},
- [gameTree, controller],
+ [controller.tree, controller],
)
const reset = () => {
const newTree = new GameTree(config.startFen || nullFen)
- setGameTree(newTree)
+ controller.tree = newTree
controller.setCurrentNode(newTree.getRoot())
setResigned(false)
setLastMoveTime(0)
@@ -278,7 +278,7 @@ export const usePlayController = (id: string, config: PlayGameConfig) => {
return {
game,
- gameTree,
+ gameTree: controller.tree,
currentNode: controller.currentNode,
player: config.player,
playType: config.playType,
@@ -305,7 +305,6 @@ export const usePlayController = (id: string, config: PlayGameConfig) => {
orientation: controller.orientation,
setOrientation: controller.setOrientation,
- addMove,
addMoveWithTime,
setResigned,
reset,
diff --git a/src/hooks/usePlayController/useVsMaiaController.ts b/src/hooks/usePlayController/useVsMaiaController.ts
index cd101e95..2d1c20eb 100644
--- a/src/hooks/usePlayController/useVsMaiaController.ts
+++ b/src/hooks/usePlayController/useVsMaiaController.ts
@@ -4,12 +4,12 @@ import { PlayGameConfig } from 'src/types'
import { backOff } from 'exponential-backoff'
import { useStats } from 'src/hooks/useStats'
import { usePlayController } from 'src/hooks/usePlayController'
-import { getGameMove, submitGameMove, getPlayPlayerStats } from 'src/api'
-import { chessSoundManager } from 'src/lib/chessSoundManager'
+import { fetchGameMove, logGameMove, fetchPlayPlayerStats } from 'src/api'
+import { chessSoundManager } from 'src/lib/sound'
import { safeUpdateRating } from 'src/lib/ratingUtils'
const playStatsLoader = async () => {
- const stats = await getPlayPlayerStats()
+ const stats = await fetchPlayPlayerStats()
return {
gamesPlayed: stats.playGamesPlayed,
gamesWon: stats.playWon,
@@ -39,6 +39,7 @@ export const useVsMaiaPlayController = (
!controller.playerActive &&
!controller.game.termination
) {
+ console.log('node before move', controller.currentNode.fen)
const maiaClock =
(controller.player == 'white'
? controller.blackClock
@@ -49,7 +50,7 @@ export const useVsMaiaPlayController = (
const maiaMoves = await backOff(
() =>
- getGameMove(
+ fetchGameMove(
controller.moveList,
playGameConfig.maiaVersion,
playGameConfig.startFen,
@@ -111,7 +112,7 @@ export const useVsMaiaPlayController = (
const submitFn = async () => {
const response = await backOff(
() =>
- submitGameMove(
+ logGameMove(
controller.game.id,
controller.moveList,
controller.moveTimes,
@@ -125,10 +126,8 @@ export const useVsMaiaPlayController = (
},
)
- // Only update stats after final move submitted
if (controller.game.termination) {
const winner = controller.game.termination?.winner
- // Safely update rating - only if the response contains a valid rating
safeUpdateRating(response.player_elo, updateRating)
incrementStats(1, winner == playGameConfig.player ? 1 : 0)
}
diff --git a/src/hooks/useTrainingController/useTrainingController.ts b/src/hooks/useTrainingController/useTrainingController.ts
index 21905f4c..addddfc4 100644
--- a/src/hooks/useTrainingController/useTrainingController.ts
+++ b/src/hooks/useTrainingController/useTrainingController.ts
@@ -1,10 +1,10 @@
import { Chess } from 'chess.ts'
import { GameTree } from 'src/types'
-import { TrainingGame } from 'src/types/training'
+import { PuzzleGame } from 'src/types/puzzle'
import { useMemo, useCallback, useEffect } from 'react'
import { useTreeController } from '../useTreeController'
-const buildTrainingGameTree = (game: TrainingGame): GameTree => {
+const buildTrainingGameTree = (game: PuzzleGame): GameTree => {
if (!game.moves || game.moves.length === 0) {
return new GameTree(new Chess().fen())
}
@@ -16,19 +16,16 @@ const buildTrainingGameTree = (game: TrainingGame): GameTree => {
for (let i = 1; i < game.moves.length; i++) {
const move = game.moves[i]
if (move.uci && move.san) {
- currentNode = tree.addMainMove(
- currentNode,
- move.board,
- move.uci,
- move.san,
- )
+ currentNode = tree
+ .getLastMainlineNode()
+ .addChild(move.board, move.uci, move.san, true),
}
}
return tree
}
-export const useTrainingController = (game: TrainingGame) => {
+export const useTrainingController = (game: PuzzleGame) => {
const gameTree = useMemo(() => buildTrainingGameTree(game), [game])
const initialOrientation = useMemo(() => {
const puzzleFen = game.moves[game.targetIndex].board
diff --git a/src/hooks/useTreeController/useTreeController.ts b/src/hooks/useTreeController/useTreeController.ts
index a3bf59c6..6ee486fa 100644
--- a/src/hooks/useTreeController/useTreeController.ts
+++ b/src/hooks/useTreeController/useTreeController.ts
@@ -5,13 +5,14 @@ export const useTreeController = (
gameTree: GameTree,
initialOrientation: Color = 'white',
) => {
- const [currentNode, setCurrentNode] = useState(gameTree.getRoot())
+ const [tree, setTree] = useState(gameTree)
+ const [currentNode, setCurrentNode] = useState(tree.getRoot())
const [orientation, setOrientation] = useState(initialOrientation)
const plyCount = useMemo(() => {
- if (!gameTree) return 0
- return gameTree.getMainLine().length
- }, [gameTree])
+ if (!tree) return 0
+ return tree.getMainLine().length
+ }, [tree])
const goToNode = useCallback(
(node: GameNode) => {
@@ -33,17 +34,17 @@ export const useTreeController = (
}, [currentNode, setCurrentNode])
const goToRootNode = useCallback(() => {
- if (gameTree) {
- setCurrentNode(gameTree.getRoot())
+ if (tree) {
+ setCurrentNode(tree.getRoot())
}
- }, [gameTree, setCurrentNode])
+ }, [tree, setCurrentNode])
useEffect(() => {
setOrientation(initialOrientation)
}, [initialOrientation])
return {
- gameTree,
+ tree,
currentNode,
setCurrentNode,
orientation,
diff --git a/src/hooks/useTuringController/useTuringController.ts b/src/hooks/useTuringController/useTuringController.ts
index dcf07d72..e00d936f 100644
--- a/src/hooks/useTuringController/useTuringController.ts
+++ b/src/hooks/useTuringController/useTuringController.ts
@@ -6,10 +6,14 @@ import { useStats } from '../useStats'
import { Color, GameTree } from 'src/types'
import { TuringGame } from 'src/types/turing'
import { useTreeController } from '../useTreeController'
-import { getTuringGame, getTuringPlayerStats, submitTuringGuess } from 'src/api'
+import {
+ fetchTuringGame,
+ fetchTuringPlayerStats,
+ submitTuringGuess,
+} from 'src/api'
const statsLoader = async () => {
- const stats = await getTuringPlayerStats()
+ const stats = await fetchTuringPlayerStats()
return {
gamesPlayed: stats.correctGuesses + stats.wrongGuesses,
gamesWon: stats.correctGuesses,
@@ -30,7 +34,9 @@ const buildTuringGameTree = (game: TuringGame): GameTree => {
const move = game.moves[i]
const uci = move.uci || (move.lastMove ? move.lastMove.join('') : undefined)
if (uci && move.san) {
- currentNode = tree.addMainMove(currentNode, move.board, uci, move.san)
+ currentNode = tree
+ .getLastMainlineNode()
+ .addChild(move.board, uci, move.san, true)
}
}
@@ -51,7 +57,7 @@ export const useTuringController = () => {
setLoading(true)
let game
try {
- game = await getTuringGame()
+ game = await fetchTuringGame()
} catch (e) {
router.push('/401')
return
@@ -112,6 +118,7 @@ export const useTuringController = () => {
return {
gameTree,
currentNode: controller.currentNode,
+ setCurrentNode: controller.setCurrentNode,
goToNode: controller.goToNode,
goToNextNode: controller.goToNextNode,
goToPreviousNode: controller.goToPreviousNode,
diff --git a/src/lib/analysis.ts b/src/lib/analysis.ts
new file mode 100644
index 00000000..821fb9dc
--- /dev/null
+++ b/src/lib/analysis.ts
@@ -0,0 +1,570 @@
+import { Chess } from 'chess.ts'
+import {
+ GameTree,
+ GameNode,
+ RawMove,
+ MistakePosition,
+ MoveValueMapping,
+ StockfishEvaluation,
+ CachedEngineAnalysisEntry,
+} from 'src/types'
+
+export function convertBackendEvalToStockfishEval(
+ possibleMoves: MoveValueMapping,
+ turn: 'w' | 'b',
+): StockfishEvaluation {
+ const cp_vec: { [key: string]: number } = {}
+ const cp_relative_vec: { [key: string]: number } = {}
+ let model_optimal_cp = -Infinity
+ let model_move = ''
+
+ for (const move in possibleMoves) {
+ const cp = possibleMoves[move]
+ cp_vec[move] = cp
+ if (cp > model_optimal_cp) {
+ model_optimal_cp = cp
+ model_move = move
+ }
+ }
+
+ for (const move in cp_vec) {
+ const cp = possibleMoves[move]
+ cp_relative_vec[move] = cp - model_optimal_cp
+ }
+
+ const cp_vec_sorted = Object.fromEntries(
+ Object.entries(cp_vec).sort(([, a], [, b]) => b - a),
+ )
+
+ const cp_relative_vec_sorted = Object.fromEntries(
+ Object.entries(cp_relative_vec).sort(([, a], [, b]) => b - a),
+ )
+
+ const winrate_vec: { [key: string]: number } = {}
+ let max_winrate = -Infinity
+
+ for (const move in cp_vec_sorted) {
+ const cp = cp_vec_sorted[move]
+ const winrate = cpToWinrate(cp, false)
+ winrate_vec[move] = winrate
+
+ if (winrate_vec[move] > max_winrate) {
+ max_winrate = winrate_vec[move]
+ }
+ }
+
+ const winrate_loss_vec: { [key: string]: number } = {}
+ for (const move in winrate_vec) {
+ winrate_loss_vec[move] = winrate_vec[move] - max_winrate
+ }
+
+ const winrate_vec_sorted = Object.fromEntries(
+ Object.entries(winrate_vec).sort(([, a], [, b]) => b - a),
+ )
+
+ const winrate_loss_vec_sorted = Object.fromEntries(
+ Object.entries(winrate_loss_vec).sort(([, a], [, b]) => b - a),
+ )
+
+ if (turn === 'b') {
+ model_optimal_cp *= -1
+ for (const move in cp_vec_sorted) {
+ cp_vec_sorted[move] *= -1
+ }
+ }
+
+ return {
+ sent: true,
+ depth: 20,
+ model_move: model_move,
+ model_optimal_cp: model_optimal_cp,
+ cp_vec: cp_vec_sorted,
+ cp_relative_vec: cp_relative_vec_sorted,
+ winrate_vec: winrate_vec_sorted,
+ winrate_loss_vec: winrate_loss_vec_sorted,
+ }
+}
+
+export function insertBackendStockfishEvalToGameTree(
+ tree: GameTree,
+ moves: RawMove[],
+ stockfishEvaluations: MoveValueMapping[],
+) {
+ let currentNode: GameNode | null = tree.getRoot()
+
+ for (let i = 0; i < moves.length; i++) {
+ if (!currentNode) {
+ break
+ }
+
+ const stockfishEval = stockfishEvaluations[i]
+ ? convertBackendEvalToStockfishEval(
+ stockfishEvaluations[i],
+ moves[i].board.split(' ')[1] as 'w' | 'b',
+ )
+ : undefined
+
+ if (stockfishEval) {
+ currentNode.addStockfishAnalysis(stockfishEval)
+ }
+ currentNode = currentNode?.mainChild
+ }
+}
+
+export const collectEngineAnalysisData = (
+ gameTree: GameTree,
+): CachedEngineAnalysisEntry[] => {
+ const positions: CachedEngineAnalysisEntry[] = []
+ const mainLine = gameTree.getMainLine()
+
+ mainLine.forEach((node, index) => {
+ if (!node.analysis.maia && !node.analysis.stockfish) {
+ return
+ }
+
+ const position: CachedEngineAnalysisEntry = {
+ ply: index,
+ fen: node.fen,
+ }
+
+ if (node.analysis.maia) {
+ position.maia = node.analysis.maia
+ }
+
+ if (node.analysis.stockfish) {
+ position.stockfish = {
+ depth: node.analysis.stockfish.depth,
+ cp_vec: node.analysis.stockfish.cp_vec,
+ }
+ }
+
+ positions.push(position)
+ })
+
+ return positions
+}
+
+const reconstructCachedStockfishAnalysis = (
+ cpVec: { [move: string]: number },
+ depth: number,
+ fen: string,
+) => {
+ const board = new Chess(fen)
+ const isBlackTurn = board.turn() === 'b'
+
+ let bestCp = isBlackTurn ? Infinity : -Infinity
+ let bestMove = ''
+
+ for (const move in cpVec) {
+ const cp = cpVec[move]
+ if (isBlackTurn) {
+ if (cp < bestCp) {
+ bestCp = cp
+ bestMove = move
+ }
+ } else {
+ if (cp > bestCp) {
+ bestCp = cp
+ bestMove = move
+ }
+ }
+ }
+
+ const cp_relative_vec: { [move: string]: number } = {}
+ for (const move in cpVec) {
+ const cp = cpVec[move]
+ cp_relative_vec[move] = isBlackTurn ? bestCp - cp : cp - bestCp
+ }
+
+ const winrate_vec: { [move: string]: number } = {}
+ for (const move in cpVec) {
+ const cp = cpVec[move]
+ const winrate = cpToWinrate(cp * (isBlackTurn ? -1 : 1), false)
+ winrate_vec[move] = winrate
+ }
+
+ let bestWinrate = -Infinity
+ for (const move in winrate_vec) {
+ const wr = winrate_vec[move]
+ if (wr > bestWinrate) {
+ bestWinrate = wr
+ }
+ }
+
+ const winrate_loss_vec: { [move: string]: number } = {}
+ for (const move in winrate_vec) {
+ winrate_loss_vec[move] = winrate_vec[move] - bestWinrate
+ }
+
+ const sortedEntries = Object.entries(winrate_vec).sort(
+ ([, a], [, b]) => b - a,
+ )
+
+ const sortedWinrateVec = Object.fromEntries(sortedEntries)
+ const sortedWinrateLossVec = Object.fromEntries(
+ sortedEntries.map(([move]) => [move, winrate_loss_vec[move]]),
+ )
+
+ return {
+ sent: true,
+ depth,
+ model_move: bestMove,
+ model_optimal_cp: bestCp,
+ cp_vec: cpVec,
+ cp_relative_vec,
+ winrate_vec: sortedWinrateVec,
+ winrate_loss_vec: sortedWinrateLossVec,
+ }
+}
+
+export const applyEngineAnalysisData = (
+ gameTree: GameTree,
+ analysisData: CachedEngineAnalysisEntry[],
+): void => {
+ const mainLine = gameTree.getMainLine()
+
+ analysisData.forEach((positionData) => {
+ const { ply, maia, stockfish } = positionData
+
+ if (ply >= 0 && ply < mainLine.length) {
+ const node = mainLine[ply]
+
+ if (node.fen === positionData.fen) {
+ if (maia) {
+ node.addMaiaAnalysis(maia)
+ }
+
+ if (stockfish) {
+ const stockfishEval = reconstructCachedStockfishAnalysis(
+ stockfish.cp_vec,
+ stockfish.depth,
+ node.fen,
+ )
+
+ if (
+ !node.analysis.stockfish ||
+ node.analysis.stockfish.depth < stockfish.depth
+ ) {
+ node.addStockfishAnalysis(stockfishEval)
+ }
+ }
+ }
+ }
+ })
+}
+
+export const generateAnalysisCacheKey = (
+ analysisData: CachedEngineAnalysisEntry[],
+): string => {
+ const keyData = analysisData.map((pos) => ({
+ ply: pos.ply,
+ fen: pos.fen,
+ hasStockfish: !!pos.stockfish,
+ stockfishDepth: pos.stockfish?.depth || 0,
+ hasMaia: !!pos.maia,
+ maiaModels: pos.maia ? Object.keys(pos.maia).sort() : [],
+ }))
+
+ return JSON.stringify(keyData)
+}
+
+export function extractPlayerMistakes(
+ gameTree: GameTree,
+ playerColor: 'white' | 'black',
+): MistakePosition[] {
+ const mainLine = gameTree.getMainLine()
+ const mistakes: MistakePosition[] = []
+
+ for (let i = 1; i < mainLine.length; i++) {
+ const node = mainLine[i]
+ const isPlayerMove = node.turn === (playerColor === 'white' ? 'b' : 'w')
+
+ if (
+ isPlayerMove &&
+ (node.blunder || node.inaccuracy) &&
+ node.move &&
+ node.san
+ ) {
+ const parentNode = node.parent
+ if (!parentNode) continue
+
+ const stockfishEval = parentNode.analysis.stockfish
+ if (!stockfishEval || !stockfishEval.model_move) continue
+
+ const chess = new Chess(parentNode.fen)
+ const bestMoveResult = chess.move(stockfishEval.model_move, {
+ sloppy: true,
+ })
+ if (!bestMoveResult) continue
+
+ mistakes.push({
+ nodeId: `move-${i}`, // Simple ID based on position in main line
+ moveIndex: i, // Index of the mistake node in the main line
+ fen: parentNode.fen, // Position before the mistake
+ playedMove: node.move,
+ san: node.san,
+ type: node.blunder ? 'blunder' : 'inaccuracy',
+ bestMove: stockfishEval.model_move,
+ bestMoveSan: bestMoveResult.san,
+ playerColor,
+ })
+ }
+ }
+
+ return mistakes
+}
+
+export function getBestMoveForPosition(node: GameNode): {
+ move: string
+ san: string
+} | null {
+ const stockfishEval = node.analysis.stockfish
+ if (!stockfishEval || !stockfishEval.model_move) {
+ return null
+ }
+
+ const chess = new Chess(node.fen)
+ const moveResult = chess.move(stockfishEval.model_move, { sloppy: true })
+
+ if (!moveResult) {
+ return null
+ }
+
+ return {
+ move: stockfishEval.model_move,
+ san: moveResult.san,
+ }
+}
+
+export function isBestMove(node: GameNode, moveUci: string): boolean {
+ const bestMove = getBestMoveForPosition(node)
+ return bestMove ? bestMove.move === moveUci : false
+}
+
+export const cpLookup: Record = {
+ '-10.0': 0.16874792794783955,
+ '-9.9': 0.16985049833887045,
+ '-9.8': 0.17055738720598324,
+ '-9.7': 0.17047675058336964,
+ '-9.6': 0.17062880930380164,
+ '-9.5': 0.17173295708238823,
+ '-9.4': 0.1741832515174232,
+ '-9.3': 0.17496583875894223,
+ '-9.2': 0.17823840614328434,
+ '-9.1': 0.17891311823785128,
+ '-9.0': 0.18001756037367211,
+ '-8.9': 0.18238828856575562,
+ '-8.8': 0.18541818422621015,
+ '-8.7': 0.18732127851231173,
+ '-8.6': 0.188595528612019,
+ '-8.5': 0.19166809721845357,
+ '-8.4': 0.18989033973470404,
+ '-8.3': 0.19357848980722225,
+ '-8.2': 0.195212660943061,
+ '-8.1': 0.1985674406526584,
+ '-8.0': 0.20401430566976098,
+ '-7.9': 0.2053494594581885,
+ '-7.8': 0.2095001157139551,
+ '-7.7': 0.2127603864858716,
+ '-7.6': 0.21607609694057794,
+ '-7.5': 0.21973060624595708,
+ '-7.4': 0.2228248514176221,
+ '-7.3': 0.22299885811455433,
+ '-7.2': 0.22159517034121434,
+ '-7.1': 0.2253474128373214,
+ '-7.0': 0.22700275966883976,
+ '-6.9': 0.2277978270416834,
+ '-6.8': 0.23198254537369023,
+ '-6.7': 0.23592594616253237,
+ '-6.6': 0.24197398088661215,
+ '-6.5': 0.24743721350483228,
+ '-6.4': 0.2504634951693775,
+ '-6.3': 0.25389828445048646,
+ '-6.2': 0.2553693799097443,
+ '-6.1': 0.2574747677153313,
+ '-6.0': 0.26099610941165985,
+ '-5.9': 0.26359484469524885,
+ '-5.8': 0.26692516804140987,
+ '-5.7': 0.269205898445802,
+ '-5.6': 0.2716602975482676,
+ '-5.5': 0.2762577909143481,
+ '-5.4': 0.27686670371314326,
+ '-5.3': 0.2811527494908349,
+ '-5.2': 0.2842962444080047,
+ '-5.1': 0.28868260131133583,
+ '-5.0': 0.2914459750278813,
+ '-4.9': 0.29552500948265914,
+ '-4.8': 0.29889111624248266,
+ '-4.7': 0.30275330907688625,
+ '-4.6': 0.30483684697544633,
+ '-4.5': 0.3087308954422261,
+ '-4.4': 0.3119607377503364,
+ '-4.3': 0.3149431542506458,
+ '-4.2': 0.31853131804955126,
+ '-4.1': 0.320778152372042,
+ '-4.0': 0.32500585429582063,
+ '-3.9': 0.3287550205647155,
+ '-3.8': 0.3302152905962328,
+ '-3.7': 0.3337414440782651,
+ '-3.6': 0.3371096329087784,
+ '-3.5': 0.3408372806176929,
+ '-3.4': 0.3430467389812629,
+ '-3.3': 0.345358794898586,
+ '-3.2': 0.3488968896485116,
+ '-3.1': 0.35237319918137733,
+ '-3.0': 0.354602162593592,
+ '-2.9': 0.35921288506416393,
+ '-2.8': 0.3620978187487768,
+ '-2.7': 0.36570889434391574,
+ '-2.6': 0.36896483582772577,
+ '-2.5': 0.3738375709360098,
+ '-2.4': 0.37755946149735364,
+ '-2.3': 0.38083501393471353,
+ '-2.2': 0.3841356210618174,
+ '-2.1': 0.38833097169521236,
+ '-2.0': 0.3913569664390527,
+ '-1.9': 0.39637664590926824,
+ '-1.8': 0.4006706381318188,
+ '-1.7': 0.40568723118901173,
+ '-1.6': 0.4112309032143989,
+ '-1.5': 0.4171285506703859,
+ '-1.4': 0.422533096069275,
+ '-1.3': 0.4301262113278628,
+ '-1.2': 0.4371930420830884,
+ '-1.1': 0.44297556987180564,
+ '-1.0': 0.4456913220302985,
+ '-0.9': 0.4524847690277852,
+ '-0.8': 0.4589667426852546,
+ '-0.7': 0.46660356893847554,
+ '-0.6': 0.47734553584312966,
+ '-0.5': 0.4838742651753062,
+ '-0.4': 0.4949662422107537,
+ '-0.3': 0.5052075551297714,
+ '-0.2': 0.5134173311516534,
+ '-0.1': 0.5243603487770374,
+ '0.0': 0.526949638981131,
+ '0.1': 0.5295389291852245,
+ '0.2': 0.5664296189371014,
+ '0.3': 0.5748717155242605,
+ '0.4': 0.5869360163496304,
+ '0.5': 0.5921709270831235,
+ '0.6': 0.6012651707026009,
+ '0.7': 0.6088638279243903,
+ '0.8': 0.6153816495064385,
+ '0.9': 0.6213113677421394,
+ '1.0': 0.6271095095579187,
+ '1.1': 0.632804166473034,
+ '1.2': 0.6381911052846212,
+ '1.3': 0.6442037744916549,
+ '1.4': 0.649365491330027,
+ '1.5': 0.6541269394628821,
+ '1.6': 0.6593775707102116,
+ '1.7': 0.6642844357446305,
+ '1.8': 0.6680323879474359,
+ '1.9': 0.6719097160994534,
+ '2.0': 0.6748374005101319,
+ '2.1': 0.6783363422342483,
+ '2.2': 0.6801467867275195,
+ '2.3': 0.684829276628467,
+ '2.4': 0.6867513620895516,
+ '2.5': 0.6905527274606579,
+ '2.6': 0.6926332976777145,
+ '2.7': 0.6952680187678887,
+ '2.8': 0.7001656575385784,
+ '2.9': 0.7010142788018504,
+ '3.0': 0.7047537668053925,
+ '3.1': 0.7073295468984271,
+ '3.2': 0.7106392738949507,
+ '3.3': 0.7116862871579852,
+ '3.4': 0.7149981105354425,
+ '3.5': 0.7168150290640533,
+ '3.6': 0.7181645290803663,
+ '3.7': 0.7215706104143079,
+ '3.8': 0.7257548871790502,
+ '3.9': 0.7266799478667236,
+ '4.0': 0.7312566255649167,
+ '4.1': 0.7338343990993276,
+ '4.2': 0.7363492332937249,
+ '4.3': 0.7374945601492073,
+ '4.4': 0.7413387385114591,
+ '4.5': 0.7466896678519318,
+ '4.6': 0.7461156227069248,
+ '4.7': 0.7500348334958896,
+ '4.8': 0.7525591569082364,
+ '4.9': 0.7567349424376287,
+ '5.0': 0.7596581998583797,
+ '5.1': 0.7621623413577621,
+ '5.2': 0.7644432506942895,
+ '5.3': 0.7668438338565464,
+ '5.4': 0.7697341715213551,
+ '5.5': 0.7718264040129471,
+ '5.6': 0.774903252738822,
+ '5.7': 0.777338112750803,
+ '5.8': 0.7782543748612702,
+ '5.9': 0.7815446531238023,
+ '6.0': 0.7817240741095305,
+ '6.1': 0.7843444714897749,
+ '6.2': 0.7879064312243436,
+ '6.3': 0.7894336918448414,
+ '6.4': 0.7906807160826923,
+ '6.5': 0.7958175057898639,
+ '6.6': 0.799001778895725,
+ '6.7': 0.8059773285734155,
+ '6.8': 0.8073085278260186,
+ '6.9': 0.8079169497726442,
+ '7.0': 0.8094553564231399,
+ '7.1': 0.812049156586624,
+ '7.2': 0.8080964608399982,
+ '7.3': 0.8089371589128438,
+ '7.4': 0.811884066397279,
+ '7.5': 0.8139355726992591,
+ '7.6': 0.8176447870147248,
+ '7.7': 0.8205118056812014,
+ '7.8': 0.8239310183322626,
+ '7.9': 0.8246215704824976,
+ '8.0': 0.8282444028473858,
+ '8.1': 0.8307566922119521,
+ '8.2': 0.8299970989266028,
+ '8.3': 0.8329910434137715,
+ '8.4': 0.8348790035853562,
+ '8.5': 0.8354299179013772,
+ '8.6': 0.838042734118834,
+ '8.7': 0.8386288753155167,
+ '8.8': 0.8403357337021318,
+ '8.9': 0.8438203836486884,
+ '9.0': 0.8438217313242881,
+ '9.1': 0.8451134380453753,
+ '9.2': 0.8456384082100551,
+ '9.3': 0.844182520663178,
+ '9.4': 0.8463847100484717,
+ '9.5': 0.8479706716538067,
+ '9.6': 0.8511321531494442,
+ '9.7': 0.8506127153603494,
+ '9.8': 0.8490128260556276,
+ '9.9': 0.8522167487684729,
+ '10.0': 0.8518353443061348,
+}
+
+export const cpToWinrate = (cp: number | string, allowNaN = false): number => {
+ try {
+ const numCp = (typeof cp === 'string' ? parseFloat(cp) : cp) / 100
+
+ const clampedCp = Math.max(-10.0, Math.min(10.0, numCp))
+ const roundedCp = Math.round(clampedCp * 10) / 10
+
+ const key = roundedCp.toFixed(1)
+ if (key in cpLookup) {
+ return cpLookup[key]
+ }
+
+ if (roundedCp < -10.0) return cpLookup['-10.0']
+ if (roundedCp > 10.0) return cpLookup['10.0']
+
+ return allowNaN ? Number.NaN : 0.5
+ } catch (error) {
+ if (allowNaN) {
+ return Number.NaN
+ }
+ throw error
+ }
+}
diff --git a/src/lib/analysis/index.ts b/src/lib/analysis/index.ts
deleted file mode 100644
index 84b1b493..00000000
--- a/src/lib/analysis/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export * from './positionAnalysis'
-export * from './mistakeDetection'
diff --git a/src/lib/analysis/mistakeDetection.ts b/src/lib/analysis/mistakeDetection.ts
deleted file mode 100644
index fcd1d1cc..00000000
--- a/src/lib/analysis/mistakeDetection.ts
+++ /dev/null
@@ -1,90 +0,0 @@
-import { Chess } from 'chess.ts'
-import { GameNode, GameTree } from 'src/types/base/tree'
-import { MistakePosition } from 'src/types/analysis'
-
-/**
- * Extracts all mistakes (blunders and inaccuracies) from the game tree for a specific player
- */
-export function extractPlayerMistakes(
- gameTree: GameTree,
- playerColor: 'white' | 'black',
-): MistakePosition[] {
- const mainLine = gameTree.getMainLine()
- const mistakes: MistakePosition[] = []
-
- // Skip the root node (starting position)
- for (let i = 1; i < mainLine.length; i++) {
- const node = mainLine[i]
-
- // Check if this move was made by the specified player
- const isPlayerMove = node.turn === (playerColor === 'white' ? 'b' : 'w') // opposite because turn indicates who moves next
-
- if (
- isPlayerMove &&
- (node.blunder || node.inaccuracy) &&
- node.move &&
- node.san
- ) {
- const parentNode = node.parent
- if (!parentNode) continue
-
- // Get the best move from the parent node's Stockfish analysis
- const stockfishEval = parentNode.analysis.stockfish
- if (!stockfishEval || !stockfishEval.model_move) continue
-
- // Convert the best move to SAN notation
- const chess = new Chess(parentNode.fen)
- const bestMoveResult = chess.move(stockfishEval.model_move, {
- sloppy: true,
- })
- if (!bestMoveResult) continue
-
- mistakes.push({
- nodeId: `move-${i}`, // Simple ID based on position in main line
- moveIndex: i, // Index of the mistake node in the main line
- fen: parentNode.fen, // Position before the mistake
- playedMove: node.move,
- san: node.san,
- type: node.blunder ? 'blunder' : 'inaccuracy',
- bestMove: stockfishEval.model_move,
- bestMoveSan: bestMoveResult.san,
- playerColor,
- })
- }
- }
-
- return mistakes
-}
-
-/**
- * Gets the best move for a given position from the node's Stockfish analysis
- */
-export function getBestMoveForPosition(node: GameNode): {
- move: string
- san: string
-} | null {
- const stockfishEval = node.analysis.stockfish
- if (!stockfishEval || !stockfishEval.model_move) {
- return null
- }
-
- const chess = new Chess(node.fen)
- const moveResult = chess.move(stockfishEval.model_move, { sloppy: true })
-
- if (!moveResult) {
- return null
- }
-
- return {
- move: stockfishEval.model_move,
- san: moveResult.san,
- }
-}
-
-/**
- * Checks if a move matches the best move for a position
- */
-export function isBestMove(node: GameNode, moveUci: string): boolean {
- const bestMove = getBestMoveForPosition(node)
- return bestMove ? bestMove.move === moveUci : false
-}
diff --git a/src/lib/analysis/mockAnalysisData.ts b/src/lib/analysis/mockAnalysisData.ts
deleted file mode 100644
index 7837c76f..00000000
--- a/src/lib/analysis/mockAnalysisData.ts
+++ /dev/null
@@ -1,91 +0,0 @@
-import { COLORS } from 'src/constants/analysis'
-
-export const mockAnalysisData = {
- colorSanMapping: {
- e2e4: { san: 'e4', color: COLORS.good[0] },
- d2d4: { san: 'd4', color: COLORS.good[1] },
- g1f3: { san: 'Nf3', color: COLORS.ok[0] },
- b1c3: { san: 'Nc3', color: COLORS.blunder[0] },
- },
- moveEvaluation: {
- maia: {
- value: 0.52,
- policy: {
- e2e4: 0.35,
- d2d4: 0.28,
- g1f3: 0.18,
- b1c3: 0.12,
- },
- },
- stockfish: {
- sent: true,
- depth: 15,
- model_move: 'e2e4',
- model_optimal_cp: 25,
- cp_vec: {
- e2e4: 25,
- d2d4: 20,
- g1f3: 15,
- b1c3: 10,
- },
- cp_relative_vec: {
- e2e4: 0,
- d2d4: -5,
- g1f3: -10,
- b1c3: -15,
- },
- },
- },
- recommendations: {
- maia: [
- { move: 'e2e4', prob: 0.35 },
- { move: 'd2d4', prob: 0.28 },
- { move: 'g1f3', prob: 0.18 },
- { move: 'b1c3', prob: 0.12 },
- ],
- stockfish: [
- { move: 'e2e4', cp: 25, winrate: 0.52 },
- { move: 'd2d4', cp: 20, winrate: 0.51 },
- { move: 'g1f3', cp: 15, winrate: 0.5 },
- { move: 'b1c3', cp: 10, winrate: 0.49 },
- ],
- },
- movesByRating: [
- { rating: 1100, e2e4: 45, d2d4: 35, g1f3: 15, b1c3: 5 },
- { rating: 1300, e2e4: 40, d2d4: 38, g1f3: 18, b1c3: 4 },
- { rating: 1500, e2e4: 38, d2d4: 40, g1f3: 20, b1c3: 2 },
- { rating: 1700, e2e4: 35, d2d4: 42, g1f3: 21, b1c3: 2 },
- { rating: 1900, e2e4: 33, d2d4: 44, g1f3: 22, b1c3: 1 },
- ],
- moveMap: [
- { move: 'e2e4', x: -0.1, y: 45 },
- { move: 'd2d4', x: -0.2, y: 40 },
- { move: 'g1f3', x: -0.8, y: 20 },
- { move: 'b1c3', x: -1.2, y: 15 },
- ],
- blunderMeter: {
- goodMoves: {
- probability: 65,
- moves: [
- { move: 'e2e4', probability: 35 },
- { move: 'd2d4', probability: 30 },
- ],
- },
- okMoves: {
- probability: 25,
- moves: [
- { move: 'g1f3', probability: 18 },
- { move: 'b1c3', probability: 7 },
- ],
- },
- blunderMoves: {
- probability: 10,
- moves: [
- { move: 'h2h4', probability: 5 },
- { move: 'a2a4', probability: 5 },
- ],
- },
- },
- boardDescription:
- 'This position offers multiple strategic options. Consider central control and piece development.',
-}
diff --git a/src/lib/analysis/positionAnalysis.ts b/src/lib/analysis/positionAnalysis.ts
deleted file mode 100644
index 05bf169a..00000000
--- a/src/lib/analysis/positionAnalysis.ts
+++ /dev/null
@@ -1,370 +0,0 @@
-import { Chess } from 'chess.ts'
-import { getBookMoves } from 'src/api'
-import { MAIA_MODELS } from 'src/constants/common'
-import { StockfishEvaluation, MaiaEvaluation } from 'src/types/analysis'
-
-export interface AnalysisEngines {
- stockfish: {
- streamEvaluations: (
- fen: string,
- moveCount: number,
- ) => AsyncIterable | null
- isReady: () => boolean
- }
- maia: {
- batchEvaluate: (
- fens: string[],
- ratingLevels: number[],
- thresholds: number[],
- ) => Promise<{ result: MaiaEvaluation[]; time: number }>
- status: string
- isReady: () => boolean
- }
-}
-
-export interface AnalysisOptions {
- stockfishDepth?: number
- stockfishTimeout?: number
- maiaRating?: number
- maiaThreshold?: number
- includeAllMaiaRatings?: boolean
- includeOpeningBook?: boolean
-}
-
-export interface AnalysisResult {
- fen: string
- stockfish: StockfishEvaluation | null
- maia: MaiaEvaluation | null
- maiaAllRatings?: { [key: string]: MaiaEvaluation }
- timestamp: number
-}
-
-/**
- * Analyzes a single position with Stockfish to get deep evaluation
- */
-export async function analyzePositionWithStockfish(
- fen: string,
- engines: AnalysisEngines,
- options: AnalysisOptions = {},
-): Promise {
- const { stockfishDepth = 15, stockfishTimeout = 5000 } = options
-
- return new Promise((resolve) => {
- const chess = new Chess(fen)
- const moveCount = chess.moves().length
-
- if (moveCount === 0 || !engines.stockfish.isReady()) {
- resolve(null)
- return
- }
-
- const evaluationStream = engines.stockfish.streamEvaluations(fen, moveCount)
- if (!evaluationStream) {
- resolve(null)
- return
- }
-
- let bestEvaluation: StockfishEvaluation | null = null
- const timeout = setTimeout(() => {
- resolve(bestEvaluation)
- }, stockfishTimeout)
-
- ;(async () => {
- try {
- for await (const evaluation of evaluationStream) {
- bestEvaluation = evaluation
- if (evaluation.depth >= stockfishDepth) {
- clearTimeout(timeout)
- resolve(evaluation)
- break
- }
- }
- } catch (error) {
- console.error('Error in Stockfish position analysis:', error)
- clearTimeout(timeout)
- resolve(bestEvaluation)
- }
- })()
- })
-}
-
-/**
- * Analyzes a position with Maia at a specific rating level
- */
-export async function analyzePositionWithMaia(
- fen: string,
- engines: AnalysisEngines,
- options: AnalysisOptions = {},
-): Promise {
- const {
- maiaRating = 1500,
- maiaThreshold,
- includeAllMaiaRatings = false,
- } = options
-
- // Wait for Maia to be ready
- if (!engines.maia.isReady()) {
- let retries = 0
- const maxRetries = 30 // 3 seconds with 100ms intervals
-
- while (retries < maxRetries && !engines.maia.isReady()) {
- await new Promise((resolve) => setTimeout(resolve, 100))
- retries++
- }
-
- if (!engines.maia.isReady()) {
- console.warn('Maia not ready after waiting, skipping analysis')
- return null
- }
- }
-
- try {
- if (includeAllMaiaRatings) {
- // Analyze at all rating levels (for analysis controller)
- const { result } = await engines.maia.batchEvaluate(
- Array(9).fill(fen),
- [1100, 1200, 1300, 1400, 1500, 1600, 1700, 1800, 1900],
- [1100, 1200, 1300, 1400, 1500, 1600, 1700, 1800, 1900],
- )
-
- // Return the evaluation for the specific rating
- const ratingIndex = [
- 1100, 1200, 1300, 1400, 1500, 1600, 1700, 1800, 1900,
- ].indexOf(maiaRating)
- return ratingIndex >= 0 ? result[ratingIndex] : result[4] // Default to 1500 if not found
- } else {
- // Analyze at single rating level (for background analysis)
- const threshold = maiaThreshold || maiaRating
- const { result } = await engines.maia.batchEvaluate(
- [fen],
- [maiaRating],
- [threshold],
- )
- return result.length > 0 ? result[0] : null
- }
- } catch (error) {
- console.warn('Maia analysis failed:', error)
- return null
- }
-}
-
-/**
- * Gets all Maia evaluations for all rating levels (used by analysis controller)
- */
-export async function analyzePositionWithAllMaiaRatings(
- fen: string,
- engines: AnalysisEngines,
- moveNumber = 1,
-): Promise<{ [key: string]: MaiaEvaluation } | null> {
- // Wait for Maia to be ready
- if (!engines.maia.isReady()) {
- let retries = 0
- const maxRetries = 30 // 3 seconds with 100ms intervals
-
- while (retries < maxRetries && !engines.maia.isReady()) {
- await new Promise((resolve) => setTimeout(resolve, 100))
- retries++
- }
-
- if (!engines.maia.isReady()) {
- console.warn('Maia not ready after waiting, skipping analysis')
- return null
- }
- }
-
- try {
- const chess = new Chess(fen)
-
- // Get Maia evaluations for all ratings
- const { result: maiaEval } = await engines.maia.batchEvaluate(
- Array(9).fill(fen),
- [1100, 1200, 1300, 1400, 1500, 1600, 1700, 1800, 1900],
- [1100, 1200, 1300, 1400, 1500, 1600, 1700, 1800, 1900],
- )
-
- const analysis: { [key: string]: MaiaEvaluation } = {}
-
- // Include opening book moves for early positions
- if (moveNumber <= 5) {
- try {
- const bookMoves = await getBookMoves(fen)
-
- MAIA_MODELS.forEach((model, index) => {
- const policySource = Object.keys(bookMoves[model] || {}).length
- ? bookMoves[model]
- : maiaEval[index].policy
-
- const sortedPolicy = Object.entries(policySource).sort(
- ([, a], [, b]) => (b as number) - (a as number),
- )
-
- analysis[model] = {
- value: maiaEval[index].value,
- policy: Object.fromEntries(
- sortedPolicy,
- ) as MaiaEvaluation['policy'],
- }
- })
- } catch (error) {
- console.warn('Failed to get opening book moves:', error)
- // Fallback to pure Maia analysis
- MAIA_MODELS.forEach((model, index) => {
- analysis[model] = maiaEval[index]
- })
- }
- } else {
- // Use pure Maia analysis for later positions
- MAIA_MODELS.forEach((model, index) => {
- analysis[model] = maiaEval[index]
- })
- }
-
- return analysis
- } catch (error) {
- console.warn('Maia analysis failed:', error)
- return null
- }
-}
-
-/**
- * Analyzes a single position with both Stockfish and Maia
- */
-export async function analyzePosition(
- fen: string,
- engines: AnalysisEngines,
- options: AnalysisOptions = {},
- cache?: Map,
-): Promise {
- // Check cache first
- if (cache?.has(fen)) {
- const cached = cache.get(fen)
- if (cached) {
- return cached
- }
- }
-
- const result: AnalysisResult = {
- fen,
- stockfish: null,
- maia: null,
- timestamp: Date.now(),
- }
-
- // Run both analyses in parallel
- const [stockfishEval, maiaEval] = await Promise.all([
- analyzePositionWithStockfish(fen, engines, options),
- analyzePositionWithMaia(fen, engines, options),
- ])
-
- result.stockfish = stockfishEval
- result.maia = maiaEval
-
- // Cache the result
- cache?.set(fen, result)
-
- return result
-}
-
-/**
- * Analyzes multiple positions in batch
- */
-export async function analyzeBatch(
- fens: string[],
- engines: AnalysisEngines,
- options: AnalysisOptions = {},
- cache?: Map,
- onProgress?: (completed: number, total: number) => void,
-): Promise {
- const results: AnalysisResult[] = []
- let completed = 0
-
- for (const fen of fens) {
- const result = await analyzePosition(fen, engines, options, cache)
- results.push(result)
- completed++
- onProgress?.(completed, fens.length)
- }
-
- return results
-}
-
-/**
- * Utility to extract Maia rating number from model string
- */
-export function extractMaiaRating(maiaModel: string): number {
- const match = maiaModel.match(/(\d{4})/)
- return match ? parseInt(match[1]) : 1500
-}
-
-/**
- * Utility to get the current user's Maia model from localStorage
- */
-export function getCurrentMaiaModel(): string {
- if (typeof window !== 'undefined') {
- const stored = localStorage.getItem('currentMaiaModel')
- if (stored && MAIA_MODELS.includes(stored)) {
- return stored
- }
- }
- return MAIA_MODELS[0] // Default to first model
-}
-
-/**
- * Creates a properly wrapped engine interface that handles initialization states
- */
-export function createEngineWrapper(
- stockfishEngine: {
- streamEvaluations: (
- fen: string,
- moveCount: number,
- ) => AsyncIterable | null
- isReady: () => boolean
- },
- maiaEngine: {
- batchEvaluate: (
- fens: string[],
- ratingLevels: number[],
- thresholds: number[],
- ) => Promise<{ result: MaiaEvaluation[]; time: number }>
- },
- getStatus: () => string,
-): AnalysisEngines {
- return {
- stockfish: stockfishEngine,
- maia: {
- get status() {
- return getStatus()
- },
- isReady: () => getStatus() === 'ready',
- batchEvaluate: async (
- fens: string[],
- ratingLevels: number[],
- thresholds: number[],
- ) => {
- // Wait for Maia to be ready with retry logic (similar to useEngineAnalysis)
- let retries = 0
- const maxRetries = 30 // 3 seconds with 100ms intervals
-
- while (retries < maxRetries && getStatus() !== 'ready') {
- await new Promise((resolve) => setTimeout(resolve, 100))
- retries++
- }
-
- if (getStatus() !== 'ready') {
- throw new Error(
- `Maia engine not ready after waiting. Current status: ${getStatus()}`,
- )
- }
-
- try {
- return await maiaEngine.batchEvaluate(fens, ratingLevels, thresholds)
- } catch (error) {
- console.error('Maia batchEvaluate failed:', error)
- throw new Error(
- `Maia analysis failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
- )
- }
- },
- },
- }
-}
diff --git a/src/lib/analysisStorage.ts b/src/lib/analysisStorage.ts
deleted file mode 100644
index ba9df5a8..00000000
--- a/src/lib/analysisStorage.ts
+++ /dev/null
@@ -1,187 +0,0 @@
-import { Chess } from 'chess.ts'
-import { GameTree } from 'src/types'
-import { EngineAnalysisPosition } from 'src/api/analysis/analysis'
-import { cpToWinrate } from 'src/lib/stockfish'
-
-/**
- * Collects analysis data from a game tree to send to the backend
- */
-export const collectEngineAnalysisData = (
- gameTree: GameTree,
-): EngineAnalysisPosition[] => {
- const positions: EngineAnalysisPosition[] = []
- const mainLine = gameTree.getMainLine()
-
- mainLine.forEach((node, index) => {
- // Only include positions that have some analysis
- if (!node.analysis.maia && !node.analysis.stockfish) {
- return
- }
-
- const position: EngineAnalysisPosition = {
- ply: index,
- fen: node.fen,
- }
-
- if (node.analysis.maia) {
- position.maia = node.analysis.maia
- }
-
- if (node.analysis.stockfish) {
- position.stockfish = {
- depth: node.analysis.stockfish.depth,
- cp_vec: node.analysis.stockfish.cp_vec,
- }
- }
-
- positions.push(position)
- })
-
- return positions
-}
-
-/**
- * Applies stored analysis data back to a game tree
- */
-export const applyEngineAnalysisData = (
- gameTree: GameTree,
- analysisData: EngineAnalysisPosition[],
-): void => {
- const mainLine = gameTree.getMainLine()
-
- analysisData.forEach((positionData) => {
- const { ply, maia, stockfish } = positionData
-
- // Find the corresponding node (ply is the index in the main line)
- if (ply >= 0 && ply < mainLine.length) {
- const node = mainLine[ply]
-
- // Verify FEN matches to ensure we're applying to the correct position
- if (node.fen === positionData.fen) {
- // Apply Maia analysis
- if (maia) {
- node.addMaiaAnalysis(maia)
- }
-
- // Apply Stockfish analysis
- if (stockfish) {
- const stockfishEval = reconstructStockfishEvaluation(
- stockfish.cp_vec,
- stockfish.depth,
- node.fen,
- )
-
- // Only apply if we don't have deeper analysis already
- if (
- !node.analysis.stockfish ||
- node.analysis.stockfish.depth < stockfish.depth
- ) {
- node.addStockfishAnalysis(stockfishEval)
- }
- }
- }
- }
- })
-}
-
-/**
- * Reconstruct a complete StockfishEvaluation from stored cp_vec using the same logic as the engine
- */
-const reconstructStockfishEvaluation = (
- cpVec: { [move: string]: number },
- depth: number,
- fen: string,
-) => {
- const board = new Chess(fen)
- const isBlackTurn = board.turn() === 'b'
-
- // Find the best move and cp (model_move and model_optimal_cp)
- let bestCp = isBlackTurn ? Infinity : -Infinity
- let bestMove = ''
-
- for (const move in cpVec) {
- const cp = cpVec[move]
- if (isBlackTurn) {
- if (cp < bestCp) {
- bestCp = cp
- bestMove = move
- }
- } else {
- if (cp > bestCp) {
- bestCp = cp
- bestMove = move
- }
- }
- }
-
- // Calculate cp_relative_vec using exact same logic as engine.ts:215-217
- const cp_relative_vec: { [move: string]: number } = {}
- for (const move in cpVec) {
- const cp = cpVec[move]
- cp_relative_vec[move] = isBlackTurn
- ? bestCp - cp // Black turn: model_optimal_cp - cp
- : cp - bestCp // White turn: cp - model_optimal_cp
- }
-
- // Calculate winrate_vec using exact same logic as engine.ts:219 and 233
- const winrate_vec: { [move: string]: number } = {}
- for (const move in cpVec) {
- const cp = cpVec[move]
- // Use exact same logic as engine: cp * (isBlackTurn ? -1 : 1)
- const winrate = cpToWinrate(cp * (isBlackTurn ? -1 : 1), false)
- winrate_vec[move] = winrate
- }
-
- // Calculate winrate_loss_vec using the same logic as the engine (lines 248-264)
- let bestWinrate = -Infinity
- for (const move in winrate_vec) {
- const wr = winrate_vec[move]
- if (wr > bestWinrate) {
- bestWinrate = wr
- }
- }
-
- const winrate_loss_vec: { [move: string]: number } = {}
- for (const move in winrate_vec) {
- winrate_loss_vec[move] = winrate_vec[move] - bestWinrate
- }
-
- // Sort all vectors by winrate (descending) as done in engine.ts:267-281
- const sortedEntries = Object.entries(winrate_vec).sort(
- ([, a], [, b]) => b - a,
- )
-
- const sortedWinrateVec = Object.fromEntries(sortedEntries)
- const sortedWinrateLossVec = Object.fromEntries(
- sortedEntries.map(([move]) => [move, winrate_loss_vec[move]]),
- )
-
- return {
- sent: true,
- depth,
- model_move: bestMove,
- model_optimal_cp: bestCp,
- cp_vec: cpVec,
- cp_relative_vec,
- winrate_vec: sortedWinrateVec,
- winrate_loss_vec: sortedWinrateLossVec,
- }
-}
-
-/**
- * Generate a unique cache key for analysis data
- */
-export const generateAnalysisCacheKey = (
- analysisData: EngineAnalysisPosition[],
-): string => {
- const keyData = analysisData.map((pos) => ({
- ply: pos.ply,
- fen: pos.fen,
- hasStockfish: !!pos.stockfish,
- stockfishDepth: pos.stockfish?.depth || 0,
- hasMaia: !!pos.maia,
- maiaModels: pos.maia ? Object.keys(pos.maia).sort() : [],
- }))
-
- return JSON.stringify(keyData)
-}
diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts
index 2ebbcd79..82d2a41c 100644
--- a/src/lib/analytics.ts
+++ b/src/lib/analytics.ts
@@ -228,13 +228,9 @@ export const trackDrillSessionCompleted = (
// Analysis Page Events
export const trackAnalysisGameLoaded = (
gameSource: 'lichess' | 'custom_pgn' | 'custom_fen' | 'url_import',
- gameLengthMoves: number,
- hasExistingAnalysis: boolean,
) => {
safeTrack('analysis_game_loaded', {
game_source: gameSource,
- game_length_moves: gameLengthMoves,
- has_existing_analysis: hasExistingAnalysis,
})
}
diff --git a/src/lib/colours/index.ts b/src/lib/colours/index.ts
deleted file mode 100644
index e661a566..00000000
--- a/src/lib/colours/index.ts
+++ /dev/null
@@ -1,84 +0,0 @@
-/* eslint-disable @typescript-eslint/ban-ts-comment */
-import chroma from 'chroma-js'
-import { normalize } from '..'
-import { distToLine } from '../math'
-
-export const combine = (c1: string, c2: string, scale: number) =>
- chroma.scale([c1, c2])(scale)
-
-export const average = (c1: string, c2: string) => chroma.average([c1, c2])
-
-export const computeColour = (nx: number, ny: number) => {
- const distToNx = distToLine([nx, ny], [1, 1, -1])
- const distToPx = distToLine([nx, ny], [-1, 1, 0])
- const sqrt2 = Math.sqrt(2)
- const normalizedNx = normalize(distToNx, -sqrt2 / 2, sqrt2 / 2)
- const normalizedPx = normalize(distToPx, -sqrt2 / 2, sqrt2 / 2)
- // return combine('#000000', '#FFFFFF', normalizedNx).alpha(normalizedNx).hex()
-
- return combine(
- combine('#000000', '#FFFFFF', normalizedNx).alpha(normalizedNx).hex(),
- combine('#00FFE0', '#FF7A00', normalizedPx + 0.3).hex(),
- normalizedNx,
- ).hex()
-}
-
-export const generateColor = (
- stockfishRank: number,
- maiaRank: number,
- maxRank: number,
- redHex = '#FF0000',
- blueHex = '#0000FF',
-): string => {
- /**
- * Generates a distinct hex color based on the ranks of a move in Stockfish and Maia outputs.
- *
- * @param stockfishRank - Rank of the move in Stockfish output (1 = best).
- * @param maiaRank - Rank of the move in Maia output (1 = best).
- * @param maxRank - Maximum rank possible (for normalizing ranks).
- * @param redHex - Hex code for the red color (default "#FF0000").
- * @param blueHex - Hex code for the blue color (default "#0000FF").
- *
- * @returns A more distinct blended hex color code.
- */
-
- const normalizeRank = (rank: number) =>
- Math.pow(1 - Math.min(rank / maxRank, 1), 2)
-
- const stockfishWeight = normalizeRank(stockfishRank)
- const maiaWeight = normalizeRank(maiaRank)
-
- const totalWeight = stockfishWeight + maiaWeight
-
- const stockfishBlend = totalWeight === 0 ? 0.5 : stockfishWeight / totalWeight
- const maiaBlend = totalWeight === 0 ? 0.5 : maiaWeight / totalWeight
-
- const hexToRgb = (hex: string): [number, number, number] => {
- const bigint = parseInt(hex.slice(1), 16)
- return [(bigint >> 16) & 255, (bigint >> 8) & 255, bigint & 255]
- }
-
- const rgbToHex = ([r, g, b]: [number, number, number]): string => {
- return `#${[r, g, b].map((x) => x.toString(16).padStart(2, '0')).join('')}`
- }
-
- const redRgb = hexToRgb(redHex)
- const blueRgb = hexToRgb(blueHex)
-
- const blendedRgb: [number, number, number] = [
- Math.round(stockfishBlend * blueRgb[0] + maiaBlend * redRgb[0]),
- Math.round(stockfishBlend * blueRgb[1] + maiaBlend * redRgb[1]),
- Math.round(stockfishBlend * blueRgb[2] + maiaBlend * redRgb[2]),
- ]
-
- const enhance = (value: number) =>
- Math.min(255, Math.max(0, Math.round(value * 1.2)))
-
- const enhancedRgb: [number, number, number] = blendedRgb.map(enhance) as [
- number,
- number,
- number,
- ]
-
- return rgbToHex(enhancedRgb)
-}
diff --git a/src/lib/common.ts b/src/lib/common.ts
new file mode 100644
index 00000000..d2c40c03
--- /dev/null
+++ b/src/lib/common.ts
@@ -0,0 +1,45 @@
+import { GameTree, RawMove } from 'src/types'
+
+export function buildGameTreeFromMoveList(moves: any[], initialFen: string) {
+ const tree = new GameTree(initialFen)
+ let currentNode = tree.getRoot()
+
+ for (let i = 0; i < moves.length; i++) {
+ const move = moves[i]
+
+ if (move.lastMove) {
+ const [from, to, promotion] = move.lastMove
+ currentNode = tree
+ .getLastMainlineNode()
+ .addChild(move.board, from + to + promotion || '', move.san || '', true)
+ }
+ }
+
+ return tree
+}
+
+export function buildMovesListFromGameStates(
+ gameStates: {
+ fen: string
+ last_move: [string, string]
+ last_move_san: string
+ check: boolean
+ clock: number
+ evaluations: {
+ [prop: string]: number
+ }
+ }[],
+): RawMove[] {
+ const moves = gameStates.map((gameState) => {
+ const { last_move: lastMove, fen, check, last_move_san: san } = gameState
+
+ return {
+ board: fen,
+ lastMove,
+ san,
+ check,
+ } as RawMove
+ })
+
+ return moves
+}
diff --git a/src/lib/customAnalysis.ts b/src/lib/customAnalysis.ts
deleted file mode 100644
index 00ae70b5..00000000
--- a/src/lib/customAnalysis.ts
+++ /dev/null
@@ -1,169 +0,0 @@
-import { AnalyzedGame, AnalysisWebGame } from 'src/types'
-import { storeCustomGame } from 'src/api/analysis'
-
-export interface StoredCustomAnalysis {
- id: string
- name: string
- type: 'custom-pgn' | 'custom-fen'
- data: string // PGN or FEN data
- createdAt: string
- preview?: string // Short preview for display
-}
-
-const STORAGE_KEY = 'maia_custom_analyses'
-const MIGRATION_KEY = 'maia_custom_analyses_migrated'
-
-let migrationPromise: Promise | null = null
-
-const generatePreview = (type: 'pgn' | 'fen', data: string): string => {
- if (type === 'pgn') {
- const whiteMatch = data.match(/\[White\s+"([^"]+)"\]/)
- const blackMatch = data.match(/\[Black\s+"([^"]+)"\]/)
- if (whiteMatch && blackMatch) {
- return `${whiteMatch[1]} vs ${blackMatch[1]}`
- } else {
- return 'PGN Game'
- }
- } else {
- return 'FEN Position'
- }
-}
-
-export const saveCustomAnalysis = async (
- type: 'pgn' | 'fen',
- data: string,
- name?: string,
-): Promise => {
- const preview = generatePreview(type, data)
- const finalName = name || preview
-
- try {
- const response = await storeCustomGame({
- name: finalName,
- [type]: data,
- })
-
- const analysis: StoredCustomAnalysis = {
- id: response.id,
- name: response.name,
- type: `custom-${type}` as 'custom-pgn' | 'custom-fen',
- data,
- createdAt: response.created_at,
- preview,
- }
-
- return analysis
- } catch (error) {
- console.error('Failed to store custom game on backend:', error)
-
- const analyses = getLocalStoredCustomAnalyses()
- const id = `${type}-${Date.now()}`
-
- const analysis: StoredCustomAnalysis = {
- id,
- name: finalName,
- type: `custom-${type}` as 'custom-pgn' | 'custom-fen',
- data,
- createdAt: new Date().toISOString(),
- preview,
- }
-
- analyses.unshift(analysis)
- const trimmedAnalyses = analyses.slice(0, 50)
- localStorage.setItem(STORAGE_KEY, JSON.stringify(trimmedAnalyses))
-
- return analysis
- }
-}
-
-const getLocalStoredCustomAnalyses = (): StoredCustomAnalysis[] => {
- try {
- const stored = localStorage.getItem(STORAGE_KEY)
- return stored ? JSON.parse(stored) : []
- } catch (error) {
- console.warn('Failed to parse stored custom analyses:', error)
- return []
- }
-}
-
-const migrateLocalStorageToBackend = async (): Promise => {
- if (typeof window === 'undefined') return
-
- const hasBeenMigrated = localStorage.getItem(MIGRATION_KEY)
- if (hasBeenMigrated) return
-
- const localAnalyses = getLocalStoredCustomAnalyses()
- if (localAnalyses.length === 0) {
- localStorage.setItem(MIGRATION_KEY, 'true')
- return
- }
-
- console.log(`Migrating ${localAnalyses.length} custom analyses to backend...`)
-
- const migrationResults = []
- for (const analysis of localAnalyses) {
- try {
- const type = analysis.type === 'custom-pgn' ? 'pgn' : 'fen'
- await storeCustomGame({
- name: analysis.name,
- [type]: analysis.data,
- })
- migrationResults.push({ success: true, id: analysis.id })
- } catch (error) {
- console.warn(`Failed to migrate analysis ${analysis.id}:`, error)
- migrationResults.push({ success: false, id: analysis.id, error })
- }
- }
-
- const successCount = migrationResults.filter((r) => r.success).length
- console.log(
- `Migration completed: ${successCount}/${localAnalyses.length} analyses migrated successfully`,
- )
-
- if (successCount === localAnalyses.length) {
- localStorage.removeItem(STORAGE_KEY)
- }
-
- localStorage.setItem(MIGRATION_KEY, 'true')
-}
-
-export const ensureMigration = (): Promise => {
- if (!migrationPromise) {
- migrationPromise = migrateLocalStorageToBackend()
- }
- return migrationPromise
-}
-
-export const getStoredCustomAnalyses = (): StoredCustomAnalysis[] => {
- return getLocalStoredCustomAnalyses()
-}
-
-export const deleteCustomAnalysis = (id: string): void => {
- const analyses = getLocalStoredCustomAnalyses()
- const filtered = analyses.filter((analysis) => analysis.id !== id)
- localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered))
-}
-
-export const getCustomAnalysisById = (
- id: string,
-): StoredCustomAnalysis | undefined => {
- const analyses = getLocalStoredCustomAnalyses()
- return analyses.find((analysis) => analysis.id === id)
-}
-
-export const convertStoredAnalysisToWebGame = (
- analysis: StoredCustomAnalysis,
-): AnalysisWebGame => {
- return {
- id: analysis.id,
- type: analysis.type,
- label: analysis.name,
- result: '*',
- pgn: analysis.type === 'custom-pgn' ? analysis.data : undefined,
- }
-}
-
-export const getCustomAnalysesAsWebGames = (): AnalysisWebGame[] => {
- const stored = getLocalStoredCustomAnalyses()
- return stored.map(convertStoredAnalysisToWebGame)
-}
diff --git a/src/providers/MaiaEngineContextProvider/data/all_moves.json b/src/lib/engine/data/all_moves.json
similarity index 100%
rename from src/providers/MaiaEngineContextProvider/data/all_moves.json
rename to src/lib/engine/data/all_moves.json
diff --git a/src/providers/MaiaEngineContextProvider/data/all_moves_reversed.json b/src/lib/engine/data/all_moves_reversed.json
similarity index 100%
rename from src/providers/MaiaEngineContextProvider/data/all_moves_reversed.json
rename to src/lib/engine/data/all_moves_reversed.json
diff --git a/src/providers/MaiaEngineContextProvider/model.ts b/src/lib/engine/maia.ts
similarity index 99%
rename from src/providers/MaiaEngineContextProvider/model.ts
rename to src/lib/engine/maia.ts
index 224efd2a..7d73edf5 100644
--- a/src/providers/MaiaEngineContextProvider/model.ts
+++ b/src/lib/engine/maia.ts
@@ -1,7 +1,7 @@
import { MaiaStatus } from 'src/types'
import { InferenceSession, Tensor } from 'onnxruntime-web'
-import { mirrorMove, preprocess, allPossibleMovesReversed } from './utils'
+import { mirrorMove, preprocess, allPossibleMovesReversed } from './tensor'
import { MaiaModelStorage } from './storage'
interface MaiaOptions {
diff --git a/src/providers/StockfishEngineContextProvider/engine.ts b/src/lib/engine/stockfish.ts
similarity index 99%
rename from src/providers/StockfishEngineContextProvider/engine.ts
rename to src/lib/engine/stockfish.ts
index b048dced..9d544652 100644
--- a/src/providers/StockfishEngineContextProvider/engine.ts
+++ b/src/lib/engine/stockfish.ts
@@ -1,8 +1,7 @@
import { Chess } from 'chess.ts'
+import { cpToWinrate } from 'src/lib'
import StockfishWeb from 'lila-stockfish-web'
-
import { StockfishEvaluation } from 'src/types'
-import { cpToWinrate } from 'src/lib/stockfish'
class Engine {
private fen: string
diff --git a/src/providers/MaiaEngineContextProvider/storage.ts b/src/lib/engine/storage.ts
similarity index 100%
rename from src/providers/MaiaEngineContextProvider/storage.ts
rename to src/lib/engine/storage.ts
diff --git a/src/providers/MaiaEngineContextProvider/utils.ts b/src/lib/engine/tensor.ts
similarity index 100%
rename from src/providers/MaiaEngineContextProvider/utils.ts
rename to src/lib/engine/tensor.ts
diff --git a/src/providers/StockfishEngineContextProvider/typings.d.ts b/src/lib/engine/typings.d.ts
similarity index 100%
rename from src/providers/StockfishEngineContextProvider/typings.d.ts
rename to src/lib/engine/typings.d.ts
diff --git a/src/lib/favorites.ts b/src/lib/favorites.ts
index 3a05c4a1..d23f69a7 100644
--- a/src/lib/favorites.ts
+++ b/src/lib/favorites.ts
@@ -1,12 +1,9 @@
-import { AnalysisWebGame } from 'src/types'
-import {
- updateGameMetadata,
- getAnalysisGameList,
-} from 'src/api/analysis/analysis'
+import { MaiaGameListEntry } from 'src/types'
+import { updateGameMetadata, fetchMaiaGameList } from 'src/api/analysis'
export interface FavoriteGame {
id: string
- type: AnalysisWebGame['type']
+ type: MaiaGameListEntry['type']
originalLabel: string
customName: string
result: string
@@ -17,26 +14,24 @@ export interface FavoriteGame {
const STORAGE_KEY = 'maia_favorite_games'
const mapGameTypeToApiType = (
- gameType: AnalysisWebGame['type'],
-): 'custom' | 'play' | 'hand' | 'brain' => {
+ gameType: MaiaGameListEntry['type'],
+): 'play' | 'hand' | 'brain' | 'custom' => {
switch (gameType) {
- case 'custom-pgn':
- case 'custom-fen':
- return 'custom'
case 'play':
return 'play'
case 'hand':
return 'hand'
case 'brain':
return 'brain'
+ case 'custom':
+ return 'custom'
default:
- // Default to 'custom' for other types like 'tournament', 'pgn', 'stream'
return 'custom'
}
}
export const addFavoriteGame = async (
- game: AnalysisWebGame,
+ game: MaiaGameListEntry,
customName?: string,
): Promise => {
try {
@@ -100,7 +95,7 @@ export const addFavoriteGame = async (
export const removeFavoriteGame = async (
gameId: string,
- gameType?: AnalysisWebGame['type'],
+ gameType?: MaiaGameListEntry['type'],
): Promise => {
try {
// First try to update via API if game type is provided
@@ -142,7 +137,7 @@ export const removeFavoriteGame = async (
export const updateFavoriteName = async (
gameId: string,
customName: string,
- gameType?: AnalysisWebGame['type'],
+ gameType?: MaiaGameListEntry['type'],
): Promise => {
try {
// First try to update via API if game type is provided
@@ -199,7 +194,7 @@ const getFavoriteGamesFromStorage = (): FavoriteGame[] => {
export const getFavoriteGames = async (): Promise => {
try {
// Fetch favorites using the special "favorites" game type endpoint
- const response = await getAnalysisGameList('favorites', 1)
+ const response = await fetchMaiaGameList('favorites', 1)
// Convert API response to FavoriteGame format
if (response.games && Array.isArray(response.games)) {
@@ -207,7 +202,7 @@ export const getFavoriteGames = async (): Promise => {
(game: any) =>
({
id: game.id,
- type: game.game_type || game.type || 'custom-pgn', // Use the game_type field from API
+ type: game.game_type || game.type,
originalLabel: game.label || game.custom_name || 'Untitled',
customName: game.custom_name || game.label || 'Untitled',
result: game.result || '*',
@@ -243,7 +238,7 @@ export const getFavoriteGame = async (
export const convertFavoriteToWebGame = (
favorite: FavoriteGame,
-): AnalysisWebGame => {
+): MaiaGameListEntry => {
return {
id: favorite.id,
type: favorite.type,
@@ -253,7 +248,9 @@ export const convertFavoriteToWebGame = (
}
}
-export const getFavoritesAsWebGames = async (): Promise => {
+export const getFavoritesAsWebGames = async (): Promise<
+ MaiaGameListEntry[]
+> => {
const favorites = await getFavoriteGames()
return favorites.map(convertFavoriteToWebGame)
}
diff --git a/src/lib/index.ts b/src/lib/index.ts
index c826dea0..76978358 100644
--- a/src/lib/index.ts
+++ b/src/lib/index.ts
@@ -1,7 +1,9 @@
-export * from './colours'
-export * from './math'
-export * from './stockfish'
-export * from './customAnalysis'
-export * from './train/utils'
-export * from './openings/drillAnalysis'
+export * from './analysis'
+export * from './analytics'
+export * from './common'
+export * from './favorites'
+export * from './lichess'
+export * from './puzzle'
export * from './ratingUtils'
+export * from './settings'
+export * from './sound'
diff --git a/src/lib/lichess.ts b/src/lib/lichess.ts
new file mode 100644
index 00000000..b9a803d4
--- /dev/null
+++ b/src/lib/lichess.ts
@@ -0,0 +1,134 @@
+import { Chess } from 'chess.ts'
+import {
+ GameTree,
+ StreamedMove,
+ type LiveGame,
+ type Player,
+ type AvailableMoves,
+ StockfishEvaluation,
+} from 'src/types'
+
+export const readLichessStream =
+ (processLine: (data: any) => void) => (response: any) => {
+ const stream = response.body.getReader()
+ const matcher = /\r?\n/
+ const decoder = new TextDecoder()
+ let buf = ''
+
+ const loop = () =>
+ stream.read().then(({ done, value }: { done: boolean; value: any }) => {
+ if (done) {
+ if (buf.length > 0) processLine(JSON.parse(buf))
+ } else {
+ const chunk = decoder.decode(value, {
+ stream: true,
+ })
+ buf += chunk
+
+ const parts = (buf || '').split(matcher)
+ buf = parts.pop() as string
+ for (const i of parts.filter((p) => p)) processLine(JSON.parse(i))
+
+ return loop()
+ }
+ })
+
+ return loop()
+ }
+
+export const convertLichessStreamToLiveGame = (gameData: any): LiveGame => {
+ const { players, id } = gameData
+
+ const whitePlayer: Player = {
+ name: players?.white?.user?.id || 'White',
+ rating: players?.white?.rating,
+ }
+
+ const blackPlayer: Player = {
+ name: players?.black?.user?.id || 'Black',
+ rating: players?.black?.rating,
+ }
+
+ const startingFen =
+ gameData.initialFen ||
+ 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'
+
+ const gameStates = [
+ {
+ board: startingFen,
+ lastMove: undefined as [string, string] | undefined,
+ san: undefined as string | undefined,
+ check: false as const,
+ maia_values: {},
+ },
+ ]
+
+ const tree = new GameTree(startingFen)
+
+ return {
+ id,
+ blackPlayer,
+ whitePlayer,
+ gameType: 'stream',
+ type: 'stream' as const,
+ moves: gameStates,
+ availableMoves: new Array(gameStates.length).fill({}) as AvailableMoves[],
+ termination: undefined,
+ maiaEvaluations: [],
+ stockfishEvaluations: [],
+ loadedFen: gameData.fen,
+ loaded: false,
+ tree,
+ } as LiveGame
+}
+
+export const handleLichessStreamMove = (
+ moveData: StreamedMove,
+ currentGame: LiveGame,
+) => {
+ const { uci, fen } = moveData
+
+ if (!uci || !fen || !currentGame.tree) {
+ return currentGame
+ }
+
+ let san = uci
+ try {
+ let beforeMoveNode = currentGame.tree.getRoot()
+ while (beforeMoveNode.mainChild) {
+ beforeMoveNode = beforeMoveNode.mainChild
+ }
+
+ const chess = new Chess(beforeMoveNode.fen)
+ const move = chess.move({
+ from: uci.slice(0, 2),
+ to: uci.slice(2, 4),
+ promotion: uci[4] ? (uci[4] as any) : undefined,
+ })
+
+ if (move) {
+ san = move.san
+ }
+ } catch (error) {
+ console.warn('Could not convert UCI to SAN:', error)
+ }
+
+ let currentNode = currentGame.tree.getRoot()
+ while (currentNode.mainChild) {
+ currentNode = currentNode.mainChild
+ }
+
+ try {
+ currentGame.tree.getLastMainlineNode().addChild(fen, uci, san, true)
+ } catch (error) {
+ console.error('Error adding move to tree:', error)
+ return currentGame
+ }
+
+ const updatedAvailableMoves = [...currentGame.availableMoves, {}]
+
+ return {
+ ...currentGame,
+ availableMoves: updatedAvailableMoves,
+ }
+}
diff --git a/src/lib/math/index.ts b/src/lib/math/index.ts
deleted file mode 100644
index 2647b3ea..00000000
--- a/src/lib/math/index.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-export const distToLine = (
- [x, y]: [number, number],
- [a, b, c]: [number, number, number],
-) => {
- return (
- Math.abs(a * x + b * y + c) / Math.sqrt(Math.pow(a, 2) + Math.pow(b, 2))
- )
-}
diff --git a/src/lib/openings/drillAnalysis.ts b/src/lib/openings/drillAnalysis.ts
deleted file mode 100644
index eedc1800..00000000
--- a/src/lib/openings/drillAnalysis.ts
+++ /dev/null
@@ -1,759 +0,0 @@
-import { Chess } from 'chess.ts'
-import { StockfishEvaluation, MaiaEvaluation } from 'src/types'
-import { GameNode } from 'src/types/base/tree'
-import {
- MoveAnalysis,
- RatingComparison,
- RatingPrediction,
- EvaluationPoint,
- DrillPerformanceData,
- CompletedDrill,
- OpeningDrillGame,
-} from 'src/types/openings'
-import { cpToWinrate } from 'src/lib/stockfish'
-import { analyzePositionWithStockfish, AnalysisEngines } from 'src/lib/analysis'
-import { MIN_STOCKFISH_DEPTH } from 'src/constants/analysis'
-
-// Maia rating levels for comparison
-const MAIA_RATINGS = [1100, 1200, 1300, 1400, 1500, 1600, 1700, 1800, 1900]
-
-/**
- * Classifies a move using the unified GameNode classification
- */
-function classifyMove(
- parentNode: GameNode,
- move: string,
- currentMaiaModel?: string,
-): MoveAnalysis['classification'] {
- const classification = GameNode.classifyMove(
- parentNode,
- move,
- currentMaiaModel,
- )
-
- if (classification.blunder) {
- return 'blunder'
- } else if (classification.inaccuracy) {
- return 'inaccuracy'
- } else if (classification.excellent) {
- return 'excellent'
- }
-
- return 'good' // Normal moves are good
-}
-
-/**
- * Creates a confident rating prediction from player moves using advanced statistical analysis
- */
-async function createRatingPrediction(
- moveAnalyses: MoveAnalysis[],
- maia: {
- batchEvaluate: (
- fens: string[],
- ratingLevels: number[],
- thresholds: number[],
- ) => Promise<{ result: MaiaEvaluation[]; time: number }>
- },
-): Promise {
- const playerMoves = moveAnalyses.filter((analysis) => analysis.isPlayerMove)
-
- if (playerMoves.length === 0) {
- throw new Error('No player moves available for Maia analysis')
- }
-
- let ratingDistribution: RatingComparison[]
-
- try {
- // Verify Maia is properly initialized before proceeding
- if (!maia || !maia.batchEvaluate) {
- throw new Error('Maia engine not available')
- }
-
- // Get the FEN positions BEFORE each player move
- const positionsBeforePlayerMoves: string[] = []
-
- for (const playerMove of playerMoves) {
- if (playerMove.fenBeforeMove) {
- positionsBeforePlayerMoves.push(playerMove.fenBeforeMove)
- } else {
- const chess = new Chess(playerMove.fen)
- chess.undo()
- positionsBeforePlayerMoves.push(chess.fen())
- }
- }
-
- // Create the arrays for batch evaluation
- const fensForBatch: string[] = []
- const ratingsForBatch: number[] = []
- const thresholdsForBatch: number[] = []
-
- for (const fen of positionsBeforePlayerMoves) {
- for (let i = 0; i < MAIA_RATINGS.length; i++) {
- fensForBatch.push(fen)
- ratingsForBatch.push(MAIA_RATINGS[i])
- thresholdsForBatch.push(MAIA_RATINGS[i])
- }
- }
-
- const { result } = await maia.batchEvaluate(
- fensForBatch,
- ratingsForBatch,
- thresholdsForBatch,
- )
-
- const validResults = result.filter(
- (r) => r && r.policy && Object.keys(r.policy).length > 0,
- )
-
- if (validResults.length < result.length * 0.8) {
- throw new Error(
- `Insufficient Maia data: only ${validResults.length}/${result.length} valid results`,
- )
- }
-
- ratingDistribution = await analyzeMaiaData(
- playerMoves,
- result,
- positionsBeforePlayerMoves,
- )
- } catch (error) {
- throw error
- }
-
- // Find the rating level with the highest log likelihood (most likely level)
- const mostLikelyRating = ratingDistribution.reduce((best, current) =>
- current.logLikelihood > best.logLikelihood ? current : best,
- )
- const predictedRating = mostLikelyRating.rating
-
- // Calculate standard deviation (uncertainty) based on spread of likelihood probabilities
- // This gives a sense of how confident we are in the prediction
- const weightedVariance = ratingDistribution.reduce(
- (sum, item) =>
- sum +
- item.likelihoodProbability * Math.pow(item.rating - predictedRating, 2),
- 0,
- )
- const standardDeviation = Math.sqrt(weightedVariance)
-
- return {
- predictedRating,
- standardDeviation,
- sampleSize: playerMoves.length,
- ratingDistribution,
- }
-}
-
-/**
- * Analyzes Maia evaluation data to create rating distribution
- */
-async function analyzeMaiaData(
- playerMoves: MoveAnalysis[],
- maiaResults: MaiaEvaluation[],
- _positionsBeforePlayerMoves: string[],
-): Promise {
- const ratingMatches: { [rating: number]: number } = {}
- const logLikelihoods: { [rating: number]: number } = {}
- const moveProbabilities: { [rating: number]: number[] } = {}
-
- MAIA_RATINGS.forEach((rating) => {
- ratingMatches[rating] = 0
- logLikelihoods[rating] = 0
- moveProbabilities[rating] = []
- })
-
- // Process each move
- playerMoves.forEach((moveAnalysis, moveIndex) => {
- MAIA_RATINGS.forEach((rating, ratingIndex) => {
- const resultIndex = moveIndex * MAIA_RATINGS.length + ratingIndex
- const maiaEval = maiaResults[resultIndex]
-
- if (
- maiaEval &&
- maiaEval.policy &&
- Object.keys(maiaEval.policy).length > 0
- ) {
- // Check if player's move matches Maia's top choice
- const topMove = Object.keys(maiaEval.policy).reduce((a, b) =>
- maiaEval.policy[a] > maiaEval.policy[b] ? a : b,
- )
- const isTopMove = topMove === moveAnalysis.move
- if (isTopMove) {
- ratingMatches[rating]++
- }
-
- // Get probability of player's actual move from Maia's policy
- const moveProb = maiaEval.policy[moveAnalysis.move] || 0.0001
- moveProbabilities[rating].push(moveProb)
-
- // Add to log likelihood
- logLikelihoods[rating] += Math.log(moveProb)
- }
- })
- })
-
- // Calculate statistics
- const averageMoveProbabilities: { [rating: number]: number } = {}
- MAIA_RATINGS.forEach((rating) => {
- const probs = moveProbabilities[rating]
- averageMoveProbabilities[rating] =
- probs.length > 0
- ? probs.reduce((sum, prob) => sum + prob, 0) / probs.length
- : 0
- })
-
- // Convert to probability distribution with enhanced smoothing
- const smoothedLikelihoods = smoothLogLikelihoods(logLikelihoods)
- const expValues = MAIA_RATINGS.map((rating) =>
- Math.exp(smoothedLikelihoods[rating]),
- )
- const sumExpValues = expValues.reduce((sum, val) => sum + val, 0)
-
- return MAIA_RATINGS.map((rating, index) => ({
- rating,
- probability:
- playerMoves.length > 0 ? ratingMatches[rating] / playerMoves.length : 0,
- moveMatch: ratingMatches[rating] > 0,
- logLikelihood: logLikelihoods[rating],
- likelihoodProbability: expValues[index] / sumExpValues,
- averageMoveProb: averageMoveProbabilities[rating],
- }))
-}
-
-/**
- * Smooth log likelihoods to create more realistic distributions
- */
-function smoothLogLikelihoods(logLikelihoods: { [rating: number]: number }): {
- [rating: number]: number
-} {
- const smoothed: { [rating: number]: number } = {}
- const values = MAIA_RATINGS.map((rating) => logLikelihoods[rating])
- const maxValue = Math.max(...values)
-
- // Normalize to prevent overflow
- MAIA_RATINGS.forEach((rating) => {
- smoothed[rating] = logLikelihoods[rating] - maxValue
- })
-
- // Apply mild smoothing to adjacent ratings
- const smoothingFactor = 0.1
- MAIA_RATINGS.forEach((rating, index) => {
- let smoothedValue = smoothed[rating] * (1 - smoothingFactor)
-
- // Add influence from neighbors
- if (index > 0) {
- smoothedValue += smoothed[MAIA_RATINGS[index - 1]] * (smoothingFactor / 2)
- }
- if (index < MAIA_RATINGS.length - 1) {
- smoothedValue += smoothed[MAIA_RATINGS[index + 1]] * (smoothingFactor / 2)
- }
-
- smoothed[rating] = smoothedValue
- })
-
- return smoothed
-}
-
-/**
- * Generates intelligent feedback based on drill performance
- */
-function generateDetailedFeedback(
- moveAnalyses: MoveAnalysis[],
- accuracy: number,
- ratingComparison: RatingComparison[],
- openingKnowledge: number,
-): string[] {
- const feedback: string[] = []
- const playerMoves = moveAnalyses.filter((analysis) => analysis.isPlayerMove)
-
- // Overall performance feedback
- if (accuracy >= 95) {
- feedback.push('🏆 Outstanding performance! You played at master level.')
- } else if (accuracy >= 85) {
- feedback.push('⭐ Excellent performance! Very strong opening play.')
- } else if (accuracy >= 75) {
- feedback.push('👍 Good performance! You know this opening well.')
- } else if (accuracy >= 60) {
- feedback.push("📚 Decent performance, but there's room for improvement.")
- } else {
- feedback.push(
- '🎯 This opening needs more practice. Focus on the key moves.',
- )
- }
-
- // Move quality analysis
- const excellentMoves = playerMoves.filter(
- (m) => m.classification === 'excellent',
- ).length
- const blunders = playerMoves.filter(
- (m) => m.classification === 'blunder',
- ).length
- // Removed 'mistake' classification - now uses inaccuracy instead
-
- if (excellentMoves > playerMoves.length / 2) {
- feedback.push(
- `💎 ${excellentMoves} excellent moves - you found the best continuations!`,
- )
- }
-
- if (blunders === 0) {
- feedback.push('🎯 Perfect accuracy! No serious errors detected.')
- feedback.push('✨ No blunders - great positional awareness!')
- } else if (blunders === 1) {
- feedback.push('⚠️ One blunder detected - review that critical moment.')
- } else {
- feedback.push(
- `🚨 ${blunders} blunders found - focus on calculation accuracy.`,
- )
- }
-
- // Rating comparison feedback
- const bestRatingMatch = ratingComparison
- .filter((r) => r.probability > 0.5)
- .sort((a, b) => b.rating - a.rating)[0]
-
- if (bestRatingMatch) {
- feedback.push(
- `🎖️ Your moves resembled a ${bestRatingMatch.rating}-rated player most closely.`,
- )
- }
-
- // Opening knowledge feedback
- if (openingKnowledge >= 90) {
- feedback.push('📖 Excellent opening theory knowledge!')
- } else if (openingKnowledge >= 70) {
- feedback.push('📚 Good grasp of opening principles.')
- } else {
- feedback.push("📖 Consider studying this opening's key ideas more deeply.")
- }
-
- return feedback
-}
-
-/**
- * Comprehensive analysis of a completed drill using Stockfish and Maia
- */
-export async function analyzeDrillPerformance(
- drillGame: OpeningDrillGame,
- finalNode: GameNode,
- engines: AnalysisEngines,
- analysisCache?: Map<
- string,
- {
- fen: string
- stockfish: StockfishEvaluation | null
- maia: MaiaEvaluation | null
- timestamp: number
- }
- >,
- onProgress?: (progress: {
- completed: number
- total: number
- currentStep: string
- }) => void,
-): Promise {
- try {
- // Reconstruct the game path for analysis
- const gameNodes: GameNode[] = []
- let currentNode: GameNode | null = finalNode
-
- // Traverse back to opening end to get all nodes
- while (currentNode && currentNode !== drillGame.openingEndNode) {
- gameNodes.unshift(currentNode)
- currentNode = currentNode.parent
- }
-
- if (drillGame.openingEndNode) {
- gameNodes.unshift(drillGame.openingEndNode)
- }
-
- const moveAnalyses: MoveAnalysis[] = []
- const evaluationChart: EvaluationPoint[] = []
- let previousEvaluation: number | null = null
-
- // Pre-scan to determine which positions need actual analysis vs are cached
- const positionsToAnalyze: { node: GameNode; index: number }[] = []
- const totalPositions = gameNodes.length - 1
-
- for (let i = 0; i < gameNodes.length - 1; i++) {
- const currentGameNode = gameNodes[i]
- const nextNode = gameNodes[i + 1]
-
- if (!nextNode.move || !nextNode.san) continue
-
- // Check if position needs analysis (either missing or not deep enough)
- const cachedDepth =
- analysisCache?.get(currentGameNode.fen)?.stockfish?.depth ?? 0
-
- const nodeDepth = currentGameNode.analysis?.stockfish?.depth ?? 0
-
- const hasSufficientAnalysis =
- Math.max(cachedDepth, nodeDepth) >= MIN_STOCKFISH_DEPTH
-
- if (!hasSufficientAnalysis) {
- positionsToAnalyze.push({ node: currentGameNode, index: i })
- }
- }
-
- const uncachedPositions = positionsToAnalyze.length
- const cachedPositions = totalPositions - uncachedPositions
- // Only show progress if there are actually positions to analyze
- if (uncachedPositions > 0) {
- onProgress?.({
- completed: 0,
- total: uncachedPositions,
- currentStep: `Analyzing ${uncachedPositions} positions...`,
- })
- }
-
- // Analyze each position
- let analyzedCount = 0
- for (let i = 0; i < gameNodes.length - 1; i++) {
- const currentGameNode = gameNodes[i]
- const nextNode = gameNodes[i + 1]
-
- if (!nextNode.move || !nextNode.san) continue
-
- const isUncached = positionsToAnalyze.some((p) => p.index === i)
- const chess = new Chess(nextNode.fen)
- const isPlayerMove =
- drillGame.selection.playerColor === 'white'
- ? chess.turn() === 'b' // If Black is to move, White just moved
- : chess.turn() === 'w' // If White is to move, Black just moved
-
- // Get Stockfish evaluation, ensuring minimum depth.
- let evaluation: StockfishEvaluation | null =
- currentGameNode.analysis?.stockfish || null
-
- // If cache has a deeper evaluation, prefer it
- const cachedEval = analysisCache?.get(currentGameNode.fen)?.stockfish
- if (cachedEval && (!evaluation || cachedEval.depth > evaluation.depth)) {
- evaluation = cachedEval
- // Save to node for future reuse
- currentGameNode.addStockfishAnalysis(evaluation, 'maia_kdd_1500')
- }
-
- // If analysis is missing or not deep enough, (re-)analyze to the required depth.
- if (!evaluation || evaluation.depth < MIN_STOCKFISH_DEPTH) {
- const deeperEvaluation = await analyzePositionWithStockfish(
- currentGameNode.fen,
- engines,
- {
- stockfishDepth: MIN_STOCKFISH_DEPTH,
- stockfishTimeout: 10000,
- }, // Increased timeout for safety
- )
-
- // Update node with deeper analysis if successful and it's actually deeper
- if (
- deeperEvaluation &&
- (!evaluation || deeperEvaluation.depth > evaluation.depth)
- ) {
- evaluation = deeperEvaluation
- // This updates the game tree, making the deeper analysis available to the rest of the app
- currentGameNode.addStockfishAnalysis(evaluation, 'maia_kdd_1500')
-
- // Store in external cache for future reuse
- if (analysisCache) {
- const existingCacheEntry = analysisCache.get(currentGameNode.fen)
- analysisCache.set(currentGameNode.fen, {
- fen: currentGameNode.fen,
- stockfish: evaluation,
- maia: existingCacheEntry?.maia || null,
- timestamp: Date.now(),
- })
- }
- }
-
- // Increment analysis count only for positions that actually needed analysis
- if (isUncached) {
- analyzedCount++
- // Update progress after completing analysis
- if (uncachedPositions > 0) {
- onProgress?.({
- completed: analyzedCount,
- total: uncachedPositions,
- currentStep:
- analyzedCount >= uncachedPositions
- ? 'Analysis complete!'
- : `Analyzing position ${analyzedCount}/${uncachedPositions}...`,
- })
- }
- }
- }
-
- if (evaluation) {
- const playedMove = nextNode.move
- const playedMoveEval = evaluation.cp_vec[playedMove] || 0
- const bestMove = evaluation.model_move
- const bestEval = evaluation.model_optimal_cp
- const evaluationLoss = playedMoveEval - bestEval
-
- // Get Maia best move if available
- let maiaBestMove: string | undefined
- try {
- // Prioritize existing analysis on the node for a standard rating (e.g., 1500)
- let maiaEval: MaiaEvaluation | null =
- currentGameNode.analysis.maia?.['maia_kdd_1500'] || null
-
- if (!maiaEval && engines.maia && engines.maia.isReady()) {
- const maiaResult = await engines.maia.batchEvaluate(
- [currentGameNode.fen],
- [1500], // Use 1500 rating for best move
- [0.1],
- )
- maiaEval =
- maiaResult.result.length > 0 ? maiaResult.result[0] : null
-
- // Also add this new analysis to the node to prevent re-fetching
- if (maiaEval) {
- if (!currentGameNode.analysis.maia) {
- currentGameNode.analysis.maia = {}
- }
- currentGameNode.analysis.maia['maia_kdd_1500'] = maiaEval
- }
- }
-
- if (maiaEval?.policy && Object.keys(maiaEval.policy).length > 0) {
- const policy = maiaEval.policy
- maiaBestMove = Object.keys(policy).reduce((a, b) =>
- policy[a] > policy[b] ? a : b,
- )
- }
- } catch (error) {
- console.warn('Failed to get Maia best move:', error)
- }
-
- // Extract move number from FEN string
- const getMoveNumberFromFen = (fen: string): number => {
- const fenParts = fen.split(' ')
- return parseInt(fenParts[5]) || 1 // 6th part is the full move number
- }
-
- // Use previous evaluation for classification, or default to 0 for first move
- const prevEval = previousEvaluation ?? 0
-
- const moveAnalysis: MoveAnalysis = {
- move: playedMove,
- san: nextNode.san,
- fen: nextNode.fen, // Position after the move (for UI display)
- fenBeforeMove: currentGameNode.fen, // Position before the move (for Maia analysis)
- moveNumber: getMoveNumberFromFen(nextNode.fen),
- isPlayerMove,
- evaluation: playedMoveEval,
- classification: isPlayerMove
- ? classifyMove(currentGameNode, playedMove, 'maia_kdd_1500')
- : 'good',
- evaluationLoss,
- bestMove,
- bestEvaluation: bestEval,
- stockfishBestMove: bestMove,
- maiaBestMove,
- }
-
- moveAnalyses.push(moveAnalysis)
-
- // Add to evaluation chart
- evaluationChart.push({
- moveNumber: moveAnalysis.moveNumber,
- evaluation: playedMoveEval,
- isPlayerMove,
- moveClassification: moveAnalysis.classification,
- })
-
- // Update previous evaluation for next iteration
- previousEvaluation = playedMoveEval
- }
-
- // Track completion but don't update progress bar for cached positions
- // completedPositions++ // This line is removed as progress is now tracked by uncached positions
- }
-
- // Calculate performance metrics
- const playerMoves = moveAnalyses.filter((analysis) => analysis.isPlayerMove)
- const excellentMoves = playerMoves.filter(
- (m) => m.classification === 'excellent',
- ).length
- const goodMoves = playerMoves.filter(
- (m) => m.classification === 'good',
- ).length
- const inaccuracies = playerMoves.filter(
- (m) => m.classification === 'inaccuracy',
- ).length
- const blunders = playerMoves.filter(
- (m) => m.classification === 'blunder',
- ).length
-
- const accuracy =
- playerMoves.length > 0
- ? ((excellentMoves + goodMoves) / playerMoves.length) * 100
- : 100
-
- const averageEvaluationLoss =
- playerMoves.length > 0
- ? playerMoves.reduce(
- (sum, move) => sum + Math.abs(move.evaluationLoss),
- 0,
- ) / playerMoves.length
- : 0
-
- // Opening knowledge score based on early moves accuracy
- const earlyMoves = playerMoves.slice(0, Math.min(5, playerMoves.length))
- const openingKnowledge =
- earlyMoves.length > 0
- ? (earlyMoves.filter(
- (m) =>
- m.classification === 'excellent' || m.classification === 'good',
- ).length /
- earlyMoves.length) *
- 100
- : 100
-
- // Get enhanced rating prediction and comparison
- onProgress?.({
- completed: totalPositions,
- total: totalPositions + 1,
- currentStep: 'Calculating rating prediction...',
- })
- let ratingPrediction: RatingPrediction
- try {
- if (engines.maia && engines.maia.isReady()) {
- ratingPrediction = await createRatingPrediction(
- moveAnalyses,
- engines.maia,
- )
- } else {
- throw new Error('Maia engine not available for rating prediction')
- }
- } catch (maiaError) {
- console.warn('Failed to create Maia rating prediction:', maiaError)
- // Fallback rating prediction
- ratingPrediction = {
- predictedRating: 1400,
- standardDeviation: 200,
- sampleSize: playerMoves.length,
- ratingDistribution: MAIA_RATINGS.map((rating) => ({
- rating,
- probability: rating === 1400 ? 0.3 : 0.1,
- moveMatch: false,
- logLikelihood: rating === 1400 ? -1 : -3,
- likelihoodProbability: rating === 1400 ? 0.3 : 0.08,
- averageMoveProb: 0.1,
- })),
- }
- }
- const ratingComparison = ratingPrediction.ratingDistribution
-
- // Generate feedback
- const feedback = generateDetailedFeedback(
- moveAnalyses,
- accuracy,
- ratingComparison,
- openingKnowledge,
- )
-
- // Find best and worst moves
- const bestPlayerMoves = playerMoves
- .filter((m) => m.classification === 'excellent')
- .sort((a, b) => b.evaluationLoss - a.evaluationLoss)
- .slice(0, 3)
-
- const worstPlayerMoves = playerMoves
- .filter(
- (m) =>
- m.classification === 'blunder' || m.classification === 'inaccuracy',
- )
- .sort((a, b) => a.evaluationLoss - b.evaluationLoss)
- .slice(0, 3)
-
- const completedDrill: CompletedDrill = {
- selection: drillGame.selection,
- finalNode,
- playerMoves: playerMoves.map((m) => m.move),
- allMoves: drillGame.moves,
- totalMoves: playerMoves.length,
- blunders: playerMoves
- .filter((m) => m.classification === 'blunder')
- .map((m) => m.san),
- goodMoves: playerMoves
- .filter(
- (m) =>
- m.classification === 'good' || m.classification === 'excellent',
- )
- .map((m) => m.san),
- finalEvaluation:
- evaluationChart.length > 0
- ? evaluationChart[evaluationChart.length - 1].evaluation
- : 0,
- completedAt: new Date(),
- moveAnalyses,
- accuracyPercentage: accuracy,
- averageEvaluationLoss,
- }
-
- return {
- drill: completedDrill,
- evaluationChart,
- accuracy,
- blunderCount: blunders,
- goodMoveCount: goodMoves + excellentMoves,
- inaccuracyCount: inaccuracies,
- mistakeCount: 0, // Legacy field, mistakes classification removed // Legacy field, mistakes classification removed
- excellentMoveCount: excellentMoves,
- feedback,
- moveAnalyses,
- ratingComparison,
- ratingPrediction,
- bestPlayerMoves,
- worstPlayerMoves,
- averageEvaluationLoss,
- openingKnowledge,
- }
- } catch (error) {
- console.error('Error analyzing drill performance:', error)
-
- // Fallback to basic analysis if detailed analysis fails
- const playerMoveCount = Math.floor(drillGame.moves.length / 2)
- const basicAccuracy = Math.max(70, Math.min(95, 80 + Math.random() * 15))
-
- const completedDrill: CompletedDrill = {
- selection: drillGame.selection,
- finalNode,
- playerMoves: drillGame.moves.filter((_, index) =>
- drillGame.selection.playerColor === 'white'
- ? index % 2 === 0
- : index % 2 === 1,
- ),
- allMoves: drillGame.moves,
- totalMoves: playerMoveCount,
- blunders: [],
- goodMoves: [],
- finalEvaluation: 0,
- completedAt: new Date(),
- }
-
- return {
- drill: completedDrill,
- evaluationChart: [],
- accuracy: basicAccuracy,
- blunderCount: 0,
- goodMoveCount: Math.floor(playerMoveCount * 0.7),
- inaccuracyCount: 0,
- mistakeCount: 0, // Legacy field, mistakes classification removed
- excellentMoveCount: 0,
- feedback: ['Analysis temporarily unavailable. Please try again.'],
- moveAnalyses: [],
- ratingComparison: [],
- ratingPrediction: {
- predictedRating: 1400,
- standardDeviation: 300,
- sampleSize: 0,
- ratingDistribution: [],
- },
- bestPlayerMoves: [],
- worstPlayerMoves: [],
- averageEvaluationLoss: 0,
- openingKnowledge: 75,
- }
- }
-}
diff --git a/src/lib/puzzle.ts b/src/lib/puzzle.ts
new file mode 100644
index 00000000..c42ee6ed
--- /dev/null
+++ b/src/lib/puzzle.ts
@@ -0,0 +1,24 @@
+import { Chess } from 'chess.ts'
+import { GameNode } from 'src/types'
+
+export const getCurrentPlayer = (currentNode: GameNode): 'white' | 'black' => {
+ if (!currentNode) return 'white'
+ const chess = new Chess(currentNode.fen)
+ return chess.turn() === 'w' ? 'white' : 'black'
+}
+
+export const getAvailableMovesArray = (movesMap: Map) => {
+ return Array.from(movesMap.entries()).flatMap(([from, tos]) =>
+ tos.map((to) => ({ from, to })),
+ )
+}
+
+export const requiresPromotion = (
+ playedMove: [string, string],
+ availableMoves: { from: string; to: string }[],
+): boolean => {
+ const matching = availableMoves.filter((m) => {
+ return m.from === playedMove[0] && m.to === playedMove[1]
+ })
+ return matching.length > 1
+}
diff --git a/src/lib/settings.ts b/src/lib/settings.ts
index 7f5ad43b..9a02237d 100644
--- a/src/lib/settings.ts
+++ b/src/lib/settings.ts
@@ -1,9 +1,3 @@
-/**
- * Settings Management Utilities
- *
- * Utilities for managing user preferences stored in localStorage
- */
-
export interface UserSettings {
soundEnabled: boolean
chessboardTheme:
@@ -45,9 +39,6 @@ const DEFAULT_SETTINGS: UserSettings = {
chessboardTheme: 'brown',
}
-/**
- * Get user settings from localStorage with defaults
- */
export const getUserSettings = (): UserSettings => {
if (typeof window === 'undefined') {
return DEFAULT_SETTINGS
@@ -70,9 +61,6 @@ export const getUserSettings = (): UserSettings => {
}
}
-/**
- * Save user settings to localStorage
- */
export const saveUserSettings = (settings: UserSettings): void => {
if (typeof window === 'undefined') {
return
@@ -85,9 +73,6 @@ export const saveUserSettings = (settings: UserSettings): void => {
}
}
-/**
- * Update a specific setting
- */
export const updateUserSetting = (
key: K,
value: UserSettings[K],
diff --git a/src/lib/chessSoundManager.ts b/src/lib/sound.ts
similarity index 74%
rename from src/lib/chessSoundManager.ts
rename to src/lib/sound.ts
index ed91cd05..7dfe9825 100644
--- a/src/lib/chessSoundManager.ts
+++ b/src/lib/sound.ts
@@ -1,10 +1,3 @@
-/**
- * Global Chess Sound Manager
- *
- * Centralized audio management for chess moves with efficient resource pooling
- * and mobile-optimized performance.
- */
-
type SoundType = 'move' | 'capture'
interface AudioPool {
@@ -14,7 +7,7 @@ interface AudioPool {
class ChessSoundManager {
private audioPool: AudioPool
- private readonly POOL_SIZE = 3 // Multiple instances for overlapping sounds
+ private readonly POOL_SIZE = 3
private lastPlayTime = 0
private readonly DEBOUNCE_MS = 100
private isInitialized = false
@@ -26,23 +19,17 @@ class ChessSoundManager {
}
}
- /**
- * Initialize audio pool - call this once when app starts
- */
public async initialize(): Promise {
if (this.isInitialized) return
try {
- // Create audio pools
for (let i = 0; i < this.POOL_SIZE; i++) {
const moveAudio = new Audio('/assets/sound/move.mp3')
const captureAudio = new Audio('/assets/sound/capture.mp3')
- // Preload audio
moveAudio.preload = 'auto'
captureAudio.preload = 'auto'
- // Add error handling
moveAudio.addEventListener('error', (e) =>
console.warn('Failed to load move sound:', e),
)
@@ -60,29 +47,23 @@ class ChessSoundManager {
}
}
- /**
- * Play chess move sound with debouncing and capture detection
- */
public playMoveSound(isCapture = false): void {
- // Check if sounds are enabled in user settings
if (typeof window !== 'undefined') {
try {
const settings = localStorage.getItem('maia-user-settings')
if (settings) {
const userSettings = JSON.parse(settings)
if (!userSettings.soundEnabled) {
- return // Sound disabled in settings
+ return
}
}
} catch (error) {
- // If settings parsing fails, continue with default behavior
console.warn('Failed to read sound settings:', error)
}
}
const now = Date.now()
- // Debounce rapid sounds
if (now - this.lastPlayTime < this.DEBOUNCE_MS) {
return
}
@@ -100,24 +81,19 @@ class ChessSoundManager {
return
}
- // Find available audio instance (not currently playing)
let audioToPlay = audioPool.find((audio) => audio.paused || audio.ended)
- // If all are playing, use the first one (interrupt)
if (!audioToPlay) {
audioToPlay = audioPool[0]
}
try {
- // Reset audio to beginning
audioToPlay.currentTime = 0
- // Play sound
const playPromise = audioToPlay.play()
if (playPromise !== undefined) {
playPromise.catch((error) => {
- // Handle autoplay restrictions (common on mobile)
if (error.name !== 'AbortError') {
console.warn(`Failed to play ${soundType} sound:`, error)
}
@@ -130,9 +106,6 @@ class ChessSoundManager {
}
}
- /**
- * Cleanup resources when app shuts down
- */
public cleanup(): void {
Object.values(this.audioPool)
.flat()
@@ -146,20 +119,13 @@ class ChessSoundManager {
this.isInitialized = false
}
- /**
- * Check if sound manager is ready
- */
public get ready(): boolean {
return this.isInitialized
}
}
-// Global singleton instance
export const chessSoundManager = new ChessSoundManager()
-/**
- * React hook for chess sound management
- */
export const useChessSoundManager = () => {
return {
playMoveSound: chessSoundManager.playMoveSound.bind(chessSoundManager),
diff --git a/src/lib/stockfish/index.ts b/src/lib/stockfish/index.ts
deleted file mode 100644
index 45914d14..00000000
--- a/src/lib/stockfish/index.ts
+++ /dev/null
@@ -1,249 +0,0 @@
-const MIN = -8
-const MAX = 0
-
-export const normalize = (value: number, min: number, max: number) => {
- if (max == min) return 1
- return (value - min) / (max - min)
-}
-
-export const normalizeEvaluation = (
- value: number,
- min: number,
- max: number,
-) => {
- if (max == min) return 1
- return MIN + (Math.abs(value - min) / Math.abs(max - min)) * (MAX - MIN)
-}
-
-export const pseudoNL = (value: number) => {
- if (value >= -1) return value / 2 - 0.5
- return value
-}
-
-export const cpLookup: Record = {
- '-10.0': 0.16874792794783955,
- '-9.9': 0.16985049833887045,
- '-9.8': 0.17055738720598324,
- '-9.7': 0.17047675058336964,
- '-9.6': 0.17062880930380164,
- '-9.5': 0.17173295708238823,
- '-9.4': 0.1741832515174232,
- '-9.3': 0.17496583875894223,
- '-9.2': 0.17823840614328434,
- '-9.1': 0.17891311823785128,
- '-9.0': 0.18001756037367211,
- '-8.9': 0.18238828856575562,
- '-8.8': 0.18541818422621015,
- '-8.7': 0.18732127851231173,
- '-8.6': 0.188595528612019,
- '-8.5': 0.19166809721845357,
- '-8.4': 0.18989033973470404,
- '-8.3': 0.19357848980722225,
- '-8.2': 0.195212660943061,
- '-8.1': 0.1985674406526584,
- '-8.0': 0.20401430566976098,
- '-7.9': 0.2053494594581885,
- '-7.8': 0.2095001157139551,
- '-7.7': 0.2127603864858716,
- '-7.6': 0.21607609694057794,
- '-7.5': 0.21973060624595708,
- '-7.4': 0.2228248514176221,
- '-7.3': 0.22299885811455433,
- '-7.2': 0.22159517034121434,
- '-7.1': 0.2253474128373214,
- '-7.0': 0.22700275966883976,
- '-6.9': 0.2277978270416834,
- '-6.8': 0.23198254537369023,
- '-6.7': 0.23592594616253237,
- '-6.6': 0.24197398088661215,
- '-6.5': 0.24743721350483228,
- '-6.4': 0.2504634951693775,
- '-6.3': 0.25389828445048646,
- '-6.2': 0.2553693799097443,
- '-6.1': 0.2574747677153313,
- '-6.0': 0.26099610941165985,
- '-5.9': 0.26359484469524885,
- '-5.8': 0.26692516804140987,
- '-5.7': 0.269205898445802,
- '-5.6': 0.2716602975482676,
- '-5.5': 0.2762577909143481,
- '-5.4': 0.27686670371314326,
- '-5.3': 0.2811527494908349,
- '-5.2': 0.2842962444080047,
- '-5.1': 0.28868260131133583,
- '-5.0': 0.2914459750278813,
- '-4.9': 0.29552500948265914,
- '-4.8': 0.29889111624248266,
- '-4.7': 0.30275330907688625,
- '-4.6': 0.30483684697544633,
- '-4.5': 0.3087308954422261,
- '-4.4': 0.3119607377503364,
- '-4.3': 0.3149431542506458,
- '-4.2': 0.31853131804955126,
- '-4.1': 0.320778152372042,
- '-4.0': 0.32500585429582063,
- '-3.9': 0.3287550205647155,
- '-3.8': 0.3302152905962328,
- '-3.7': 0.3337414440782651,
- '-3.6': 0.3371096329087784,
- '-3.5': 0.3408372806176929,
- '-3.4': 0.3430467389812629,
- '-3.3': 0.345358794898586,
- '-3.2': 0.3488968896485116,
- '-3.1': 0.35237319918137733,
- '-3.0': 0.354602162593592,
- '-2.9': 0.35921288506416393,
- '-2.8': 0.3620978187487768,
- '-2.7': 0.36570889434391574,
- '-2.6': 0.36896483582772577,
- '-2.5': 0.3738375709360098,
- '-2.4': 0.37755946149735364,
- '-2.3': 0.38083501393471353,
- '-2.2': 0.3841356210618174,
- '-2.1': 0.38833097169521236,
- '-2.0': 0.3913569664390527,
- '-1.9': 0.39637664590926824,
- '-1.8': 0.4006706381318188,
- '-1.7': 0.40568723118901173,
- '-1.6': 0.4112309032143989,
- '-1.5': 0.4171285506703859,
- '-1.4': 0.422533096069275,
- '-1.3': 0.4301262113278628,
- '-1.2': 0.4371930420830884,
- '-1.1': 0.44297556987180564,
- '-1.0': 0.4456913220302985,
- '-0.9': 0.4524847690277852,
- '-0.8': 0.4589667426852546,
- '-0.7': 0.46660356893847554,
- '-0.6': 0.47734553584312966,
- '-0.5': 0.4838742651753062,
- '-0.4': 0.4949662422107537,
- '-0.3': 0.5052075551297714,
- '-0.2': 0.5134173311516534,
- '-0.1': 0.5243603487770374,
- '0.0': 0.526949638981131,
- '0.1': 0.5295389291852245,
- '0.2': 0.5664296189371014,
- '0.3': 0.5748717155242605,
- '0.4': 0.5869360163496304,
- '0.5': 0.5921709270831235,
- '0.6': 0.6012651707026009,
- '0.7': 0.6088638279243903,
- '0.8': 0.6153816495064385,
- '0.9': 0.6213113677421394,
- '1.0': 0.6271095095579187,
- '1.1': 0.632804166473034,
- '1.2': 0.6381911052846212,
- '1.3': 0.6442037744916549,
- '1.4': 0.649365491330027,
- '1.5': 0.6541269394628821,
- '1.6': 0.6593775707102116,
- '1.7': 0.6642844357446305,
- '1.8': 0.6680323879474359,
- '1.9': 0.6719097160994534,
- '2.0': 0.6748374005101319,
- '2.1': 0.6783363422342483,
- '2.2': 0.6801467867275195,
- '2.3': 0.684829276628467,
- '2.4': 0.6867513620895516,
- '2.5': 0.6905527274606579,
- '2.6': 0.6926332976777145,
- '2.7': 0.6952680187678887,
- '2.8': 0.7001656575385784,
- '2.9': 0.7010142788018504,
- '3.0': 0.7047537668053925,
- '3.1': 0.7073295468984271,
- '3.2': 0.7106392738949507,
- '3.3': 0.7116862871579852,
- '3.4': 0.7149981105354425,
- '3.5': 0.7168150290640533,
- '3.6': 0.7181645290803663,
- '3.7': 0.7215706104143079,
- '3.8': 0.7257548871790502,
- '3.9': 0.7266799478667236,
- '4.0': 0.7312566255649167,
- '4.1': 0.7338343990993276,
- '4.2': 0.7363492332937249,
- '4.3': 0.7374945601492073,
- '4.4': 0.7413387385114591,
- '4.5': 0.7466896678519318,
- '4.6': 0.7461156227069248,
- '4.7': 0.7500348334958896,
- '4.8': 0.7525591569082364,
- '4.9': 0.7567349424376287,
- '5.0': 0.7596581998583797,
- '5.1': 0.7621623413577621,
- '5.2': 0.7644432506942895,
- '5.3': 0.7668438338565464,
- '5.4': 0.7697341715213551,
- '5.5': 0.7718264040129471,
- '5.6': 0.774903252738822,
- '5.7': 0.777338112750803,
- '5.8': 0.7782543748612702,
- '5.9': 0.7815446531238023,
- '6.0': 0.7817240741095305,
- '6.1': 0.7843444714897749,
- '6.2': 0.7879064312243436,
- '6.3': 0.7894336918448414,
- '6.4': 0.7906807160826923,
- '6.5': 0.7958175057898639,
- '6.6': 0.799001778895725,
- '6.7': 0.8059773285734155,
- '6.8': 0.8073085278260186,
- '6.9': 0.8079169497726442,
- '7.0': 0.8094553564231399,
- '7.1': 0.812049156586624,
- '7.2': 0.8080964608399982,
- '7.3': 0.8089371589128438,
- '7.4': 0.811884066397279,
- '7.5': 0.8139355726992591,
- '7.6': 0.8176447870147248,
- '7.7': 0.8205118056812014,
- '7.8': 0.8239310183322626,
- '7.9': 0.8246215704824976,
- '8.0': 0.8282444028473858,
- '8.1': 0.8307566922119521,
- '8.2': 0.8299970989266028,
- '8.3': 0.8329910434137715,
- '8.4': 0.8348790035853562,
- '8.5': 0.8354299179013772,
- '8.6': 0.838042734118834,
- '8.7': 0.8386288753155167,
- '8.8': 0.8403357337021318,
- '8.9': 0.8438203836486884,
- '9.0': 0.8438217313242881,
- '9.1': 0.8451134380453753,
- '9.2': 0.8456384082100551,
- '9.3': 0.844182520663178,
- '9.4': 0.8463847100484717,
- '9.5': 0.8479706716538067,
- '9.6': 0.8511321531494442,
- '9.7': 0.8506127153603494,
- '9.8': 0.8490128260556276,
- '9.9': 0.8522167487684729,
- '10.0': 0.8518353443061348,
-}
-
-export const cpToWinrate = (cp: number | string, allowNaN = false): number => {
- try {
- const numCp = (typeof cp === 'string' ? parseFloat(cp) : cp) / 100
-
- const clampedCp = Math.max(-10.0, Math.min(10.0, numCp))
- const roundedCp = Math.round(clampedCp * 10) / 10
-
- const key = roundedCp.toFixed(1)
- if (key in cpLookup) {
- return cpLookup[key]
- }
-
- if (roundedCp < -10.0) return cpLookup['-10.0']
- if (roundedCp > 10.0) return cpLookup['10.0']
-
- return allowNaN ? Number.NaN : 0.5
- } catch (error) {
- if (allowNaN) {
- return Number.NaN
- }
- throw error
- }
-}
diff --git a/src/lib/train/utils.ts b/src/lib/train/utils.ts
deleted file mode 100644
index 44620766..00000000
--- a/src/lib/train/utils.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-import { Chess } from 'chess.ts'
-import { TrainingGame } from 'src/types/training'
-import {
- GameNode,
- AnalyzedGame,
- MaiaEvaluation,
- StockfishEvaluation,
-} from 'src/types'
-
-/**
- * Helper function to convert TrainingGame to AnalyzedGame
- */
-export const convertTrainingGameToAnalyzedGame = (
- trainingGame: TrainingGame,
-): AnalyzedGame => {
- const maiaEvaluations: { [rating: string]: MaiaEvaluation }[] = []
- const stockfishEvaluations: (StockfishEvaluation | undefined)[] = []
- const availableMoves = []
-
- for (let i = 0; i < trainingGame.moves.length; i++) {
- maiaEvaluations.push({})
- stockfishEvaluations.push(undefined)
- availableMoves.push({})
- }
-
- return {
- ...trainingGame,
- maiaEvaluations,
- stockfishEvaluations,
- availableMoves,
- type: 'play' as const,
- }
-}
-
-/**
- * Get the current player for promotion overlay
- */
-export const getCurrentPlayer = (currentNode: GameNode): 'white' | 'black' => {
- if (!currentNode) return 'white'
- const chess = new Chess(currentNode.fen)
- return chess.turn() === 'w' ? 'white' : 'black'
-}
-
-/**
- * Get available moves from controller for promotion detection
- */
-export const getAvailableMovesArray = (movesMap: Map) => {
- return Array.from(movesMap.entries()).flatMap(([from, tos]) =>
- tos.map((to) => ({ from, to })),
- )
-}
-
-/**
- * Check if a move requires promotion (multiple matching moves)
- */
-export const requiresPromotion = (
- playedMove: [string, string],
- availableMoves: { from: string; to: string }[],
-): boolean => {
- const matching = availableMoves.filter((m) => {
- return m.from === playedMove[0] && m.to === playedMove[1]
- })
- return matching.length > 1
-}
diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx
index 80eeee3a..c6d3331d 100644
--- a/src/pages/_app.tsx
+++ b/src/pages/_app.tsx
@@ -8,7 +8,7 @@ import posthog from 'posthog-js'
import { Open_Sans } from 'next/font/google'
import { Analytics } from '@vercel/analytics/react'
import { PostHogProvider } from 'posthog-js/react'
-import { chessSoundManager } from 'src/lib/chessSoundManager'
+import { chessSoundManager } from 'src/lib/sound'
import {
AuthContextProvider,
@@ -18,8 +18,8 @@ import {
MaiaEngineContextProvider,
StockfishEngineContextProvider,
SettingsProvider,
-} from 'src/providers'
-import { TourProvider as TourContextProvider } from 'src/contexts'
+ TourProvider as TourContextProvider,
+} from 'src/contexts'
import 'src/styles/tailwind.css'
import 'src/styles/themes.css'
import 'react-tooltip/dist/react-tooltip.css'
diff --git a/src/pages/analysis/[...id].tsx b/src/pages/analysis/[...id].tsx
index 40c187f3..c1575856 100644
--- a/src/pages/analysis/[...id].tsx
+++ b/src/pages/analysis/[...id].tsx
@@ -8,14 +8,11 @@ import React, {
SetStateAction,
} from 'react'
import {
- getLichessGamePGN,
- getAnalyzedUserGame,
- getAnalyzedLichessGame,
- getAnalyzedTournamentGame,
- getAnalyzedCustomPGN,
- getAnalyzedCustomFEN,
- getAnalyzedCustomGame,
- getEngineAnalysis,
+ fetchPgnOfLichessGame,
+ fetchAnalyzedMaiaGame,
+ fetchAnalyzedPgnGame,
+ fetchAnalyzedWorldChampionshipGame,
+ retrieveGameAnalysisCache,
} from 'src/api'
import {
AnalyzedGame,
@@ -57,8 +54,7 @@ import { useAnalysisController } from 'src/hooks'
import { tourConfigs } from 'src/constants/tours'
import type { DrawShape } from 'chessground/draw'
import { MAIA_MODELS } from 'src/constants/common'
-import { applyEngineAnalysisData } from 'src/lib/analysisStorage'
-import { deleteCustomAnalysis } from 'src/lib/customAnalysis'
+import { applyEngineAnalysisData } from 'src/lib/analysis'
const AnalysisPage: NextPage = () => {
const { startTour, tourState } = useTour()
@@ -81,32 +77,22 @@ const AnalysisPage: NextPage = () => {
}, [initialTourCheck, startTour, tourState.ready])
const [currentId, setCurrentId] = useState(id as string[])
- const loadStoredAnalysis = useCallback(async (game: AnalyzedGame) => {
- if (
- !game.id ||
- game.type === 'custom-pgn' ||
- game.type === 'custom-fen' ||
- game.type === 'tournament'
- ) {
+ const loadGameAnalysisCache = useCallback(async (game: AnalyzedGame) => {
+ if (!game.id || game.type === 'tournament') {
return
}
try {
- const storedAnalysis = await getEngineAnalysis(game.id)
+ const storedAnalysis = await retrieveGameAnalysisCache(game.id)
if (storedAnalysis && storedAnalysis.positions.length > 0) {
applyEngineAnalysisData(game.tree, storedAnalysis.positions)
- console.log(
- 'Loaded stored analysis:',
- storedAnalysis.positions.length,
- 'positions',
- )
}
} catch (error) {
console.warn('Failed to load stored analysis:', error)
}
}, [])
- const getAndSetTournamentGame = useCallback(
+ const getAndSetWorldChampionshipGame = useCallback(
async (
newId: string[],
setCurrentMove?: Dispatch>,
@@ -114,33 +100,24 @@ const AnalysisPage: NextPage = () => {
) => {
let game
try {
- game = await getAnalyzedTournamentGame(newId)
+ game = await fetchAnalyzedWorldChampionshipGame(newId)
} catch (e) {
router.push('/401')
return
}
if (setCurrentMove) setCurrentMove(0)
- // Track game loaded
- trackAnalysisGameLoaded(
- 'lichess',
- game.moves?.length || 0,
- game.maiaEvaluations?.length > 0 ||
- game.stockfishEvaluations?.length > 0,
- )
-
+ trackAnalysisGameLoaded('lichess')
setAnalyzedGame({ ...game, type: 'tournament' })
setCurrentId(newId)
- // await loadStoredAnalysis({ ...game, type: 'tournament' })
-
if (updateUrl) {
router.push(`/analysis/${newId.join('/')}`, undefined, {
shallow: true,
})
}
},
- [router, loadStoredAnalysis],
+ [router, loadGameAnalysisCache],
)
const getAndSetLichessGame = useCallback(
@@ -150,52 +127,39 @@ const AnalysisPage: NextPage = () => {
setCurrentMove?: Dispatch>,
updateUrl = true,
) => {
- let game
- try {
- game = await getAnalyzedLichessGame(id, pgn)
- } catch (e) {
- router.push('/401')
- return
- }
+ const game = await fetchAnalyzedPgnGame(id, pgn)
+
if (setCurrentMove) setCurrentMove(0)
setAnalyzedGame({
...game,
- type: 'pgn',
+ type: 'lichess',
})
- setCurrentId([id, 'pgn'])
+ setCurrentId([id, 'lichess'])
- // Load stored analysis
- await loadStoredAnalysis({ ...game, type: 'pgn' })
+ await loadGameAnalysisCache({ ...game, type: 'lichess' })
if (updateUrl) {
- router.push(`/analysis/${id}/pgn`, undefined, { shallow: true })
+ router.push(`/analysis/${id}/lichess`, undefined, { shallow: true })
}
},
- [router, loadStoredAnalysis],
+ [router, loadGameAnalysisCache],
)
const getAndSetUserGame = useCallback(
async (
id: string,
- type: 'play' | 'hand' | 'brain',
+ type: 'play' | 'hand' | 'brain' | 'custom',
setCurrentMove?: Dispatch>,
updateUrl = true,
) => {
- let game
- try {
- game = await getAnalyzedUserGame(id, type)
- } catch (e) {
- router.push('/401')
- return
- }
+ const game = await fetchAnalyzedMaiaGame(id, type)
+
if (setCurrentMove) setCurrentMove(0)
setAnalyzedGame({ ...game, type })
setCurrentId([id, type])
-
- // Load stored analysis
- await loadStoredAnalysis({ ...game, type })
+ await loadGameAnalysisCache({ ...game, type })
if (updateUrl) {
router.push(`/analysis/${id}/${type}`, undefined, {
@@ -203,58 +167,7 @@ const AnalysisPage: NextPage = () => {
})
}
},
- [router, loadStoredAnalysis],
- )
-
- const getAndSetCustomGame = useCallback(
- async (
- id: string,
- setCurrentMove?: Dispatch>,
- updateUrl = true,
- ) => {
- let game
- try {
- game = await getAnalyzedCustomGame(id)
- } catch (e) {
- toast.error((e as Error).message)
- return
- }
- if (setCurrentMove) setCurrentMove(0)
-
- setAnalyzedGame(game)
- setCurrentId([id, 'custom'])
-
- if (updateUrl) {
- router.push(`/analysis/${id}/custom`, undefined, {
- shallow: true,
- })
- }
- },
- [router],
- )
-
- const getAndSetCustomAnalysis = useCallback(
- async (type: 'pgn' | 'fen', data: string, name?: string) => {
- let game: AnalyzedGame
- try {
- if (type === 'pgn') {
- game = await getAnalyzedCustomPGN(data, name)
- } else {
- game = await getAnalyzedCustomFEN(data, name)
- }
- } catch (e) {
- toast.error((e as Error).message)
- return
- }
-
- setAnalyzedGame(game)
- setCurrentId([game.id, 'custom'])
-
- router.push(`/analysis/${game.id}/custom`, undefined, {
- shallow: true,
- })
- },
- [router],
+ [router, loadGameAnalysisCache],
)
useEffect(() => {
@@ -266,30 +179,27 @@ const AnalysisPage: NextPage = () => {
!analyzedGame || currentId.join('/') !== queryId.join('/')
if (needsNewGame) {
- if (queryId[1] === 'custom') {
- getAndSetCustomGame(queryId[0], undefined, false)
- } else if (queryId[1] === 'pgn') {
- const pgn = await getLichessGamePGN(queryId[0])
+ if (queryId[1] === 'lichess') {
+ const pgn = await fetchPgnOfLichessGame(queryId[0])
getAndSetLichessGame(queryId[0], pgn, undefined, false)
- } else if (['play', 'hand', 'brain'].includes(queryId[1])) {
+ } else if (['play', 'hand', 'brain', 'custom'].includes(queryId[1])) {
getAndSetUserGame(
queryId[0],
- queryId[1] as 'play' | 'hand' | 'brain',
+ queryId[1] as 'play' | 'hand' | 'brain' | 'custom',
undefined,
false,
)
} else {
- getAndSetTournamentGame(queryId, undefined, false)
+ getAndSetWorldChampionshipGame(queryId, undefined, false)
}
}
})()
}, [
id,
analyzedGame,
- getAndSetTournamentGame,
+ getAndSetWorldChampionshipGame,
getAndSetLichessGame,
getAndSetUserGame,
- getAndSetCustomGame,
])
return (
@@ -298,11 +208,9 @@ const AnalysisPage: NextPage = () => {
) : (
@@ -331,20 +239,10 @@ interface Props {
) => Promise
getAndSetUserGame: (
id: string,
- type: 'play' | 'hand' | 'brain',
- setCurrentMove?: Dispatch>,
- updateUrl?: boolean,
- ) => Promise
- getAndSetCustomGame: (
- id: string,
+ type: 'play' | 'hand' | 'brain' | 'custom',
setCurrentMove?: Dispatch>,
updateUrl?: boolean,
) => Promise
- getAndSetCustomAnalysis: (
- type: 'pgn' | 'fen',
- data: string,
- name?: string,
- ) => Promise
router: ReturnType
}
@@ -354,21 +252,9 @@ const Analysis: React.FC = ({
getAndSetTournamentGame,
getAndSetLichessGame,
getAndSetUserGame,
- getAndSetCustomGame,
- getAndSetCustomAnalysis,
+
router,
}: Props) => {
- const screens = [
- {
- id: 'configure',
- name: 'Configure',
- },
- {
- id: 'export',
- name: 'Export',
- },
- ]
-
const { width } = useContext(WindowSizeContext)
const isMobile = useMemo(() => width > 0 && width <= 670, [width])
const [hoverArrow, setHoverArrow] = useState(null)
@@ -379,7 +265,7 @@ const Analysis: React.FC = ({
const [showCustomModal, setShowCustomModal] = useState(false)
const [showAnalysisConfigModal, setShowAnalysisConfigModal] = useState(false)
const [refreshTrigger, setRefreshTrigger] = useState(0)
- const [analysisEnabled, setAnalysisEnabled] = useState(true) // Analysis enabled by default
+ const [analysisEnabled, setAnalysisEnabled] = useState(true)
const [lastMoveResult, setLastMoveResult] = useState<
'correct' | 'incorrect' | 'not-learning'
>('not-learning')
@@ -410,26 +296,6 @@ const Analysis: React.FC = ({
window.open(url)
}, [controller.currentNode])
- const handleCustomAnalysis = useCallback(
- async (type: 'pgn' | 'fen', data: string, name?: string) => {
- setShowCustomModal(false)
- await getAndSetCustomAnalysis(type, data, name)
- setRefreshTrigger((prev) => prev + 1)
- },
- [getAndSetCustomAnalysis],
- )
-
- const handleDeleteCustomGame = useCallback(async () => {
- if (
- analyzedGame.type === 'custom-pgn' ||
- analyzedGame.type === 'custom-fen'
- ) {
- deleteCustomAnalysis(analyzedGame.id)
- toast.success('Custom analysis deleted')
- router.push('/analysis')
- }
- }, [analyzedGame, router])
-
const handleAnalyzeEntireGame = useCallback(() => {
setShowAnalysisConfigModal(true)
}, [])
@@ -572,17 +438,13 @@ const Analysis: React.FC = ({
controller.currentNode.isMainline
) {
// No main child exists AND we're on main line - create main line move
- const newMainMove = analyzedGame.tree.addMainMove(
- controller.currentNode,
- newFen,
- moveString,
- san,
- controller.currentMaiaModel,
- )
+ const newMainMove = analyzedGame.tree
+ .getLastMainlineNode()
+ .addChild(newFen, moveString, san, true, controller.currentMaiaModel)
controller.goToNode(newMainMove)
} else {
// Either main child exists but different move, OR we're in a variation - create variation
- const newVariation = analyzedGame.tree.addVariation(
+ const newVariation = analyzedGame.tree.addVariationNode(
controller.currentNode,
newFen,
moveString,
@@ -769,18 +631,15 @@ const Analysis: React.FC = ({
+ loadNewWorldChampionshipGame={(newId, setCurrentMove) =>
getAndSetTournamentGame(newId, setCurrentMove)
}
- loadNewLichessGames={(id, pgn, setCurrentMove) =>
+ loadNewLichessGame={(id, pgn, setCurrentMove) =>
getAndSetLichessGame(id, pgn, setCurrentMove)
}
- loadNewUserGames={(id, type, setCurrentMove) =>
+ loadNewMaiaGame={(id, type, setCurrentMove) =>
getAndSetUserGame(id, type, setCurrentMove)
}
- loadNewCustomGame={(id, setCurrentMove) =>
- getAndSetCustomGame(id, setCurrentMove)
- }
onCustomAnalysis={() => setShowCustomModal(true)}
refreshTrigger={refreshTrigger}
/>
@@ -918,7 +777,6 @@ const Analysis: React.FC = ({
MAIA_MODELS={MAIA_MODELS}
game={analyzedGame}
currentNode={controller.currentNode as GameNode}
- onDeleteCustomGame={handleDeleteCustomGame}
onAnalyzeEntireGame={handleAnalyzeEntireGame}
onLearnFromMistakes={handleLearnFromMistakes}
isAnalysisInProgress={controller.gameAnalysis.progress.isAnalyzing}
@@ -986,24 +844,21 @@ const Analysis: React.FC = ({
+ loadNewWorldChampionshipGame={(newId, setCurrentMove) =>
loadGameAndCloseList(
getAndSetTournamentGame(newId, setCurrentMove),
)
}
- loadNewLichessGames={(id, pgn, setCurrentMove) =>
+ loadNewLichessGame={(id, pgn, setCurrentMove) =>
loadGameAndCloseList(
getAndSetLichessGame(id, pgn, setCurrentMove),
)
}
- loadNewUserGames={(id, type, setCurrentMove) =>
+ loadNewMaiaGame={(id, type, setCurrentMove) =>
loadGameAndCloseList(
getAndSetUserGame(id, type, setCurrentMove),
)
}
- loadNewCustomGame={(id, setCurrentMove) =>
- loadGameAndCloseList(getAndSetCustomGame(id, setCurrentMove))
- }
onCustomAnalysis={() => {
setShowCustomModal(true)
setShowGameListMobile(false)
@@ -1355,7 +1210,6 @@ const Analysis: React.FC = ({
launchContinue={launchContinue}
MAIA_MODELS={MAIA_MODELS}
game={analyzedGame}
- onDeleteCustomGame={handleDeleteCustomGame}
onAnalyzeEntireGame={handleAnalyzeEntireGame}
onLearnFromMistakes={handleLearnFromMistakes}
isAnalysisInProgress={
diff --git a/src/pages/analysis/index.tsx b/src/pages/analysis/index.tsx
index a20635c2..a0637f5e 100644
--- a/src/pages/analysis/index.tsx
+++ b/src/pages/analysis/index.tsx
@@ -1,7 +1,7 @@
import { NextPage } from 'next'
import { useRouter } from 'next/router'
import { DelayedLoading } from 'src/components'
-import { getAnalysisGameList } from 'src/api'
+import { fetchMaiaGameList } from 'src/api'
import { AnalysisListContext } from 'src/contexts'
import { useContext, useEffect, useState } from 'react'
@@ -22,7 +22,7 @@ const AnalysisPage: NextPage = () => {
// If no play games in context, try to fetch them directly
try {
- const playGames = await getAnalysisGameList('play', 1)
+ const playGames = await fetchMaiaGameList('play', 1)
if (playGames.games && playGames.games.length > 0) {
const gameId = playGames.games[0].game_id
push(`/analysis/${gameId}/play`)
diff --git a/src/pages/analysis/stream/[gameId].tsx b/src/pages/analysis/stream/[gameId].tsx
index aaecded1..ffdcfa89 100644
--- a/src/pages/analysis/stream/[gameId].tsx
+++ b/src/pages/analysis/stream/[gameId].tsx
@@ -12,7 +12,7 @@ import { useAnalysisController } from 'src/hooks'
import { TreeControllerContext } from 'src/contexts'
import { StreamAnalysis } from 'src/components/Analysis/StreamAnalysis'
import { AnalyzedGame } from 'src/types'
-import { GameTree } from 'src/types/base/tree'
+import { GameTree } from 'src/types/tree'
const StreamAnalysisPage: NextPage = () => {
const router = useRouter()
diff --git a/src/pages/leaderboard.tsx b/src/pages/leaderboard.tsx
index 6fb563d2..25abbb50 100644
--- a/src/pages/leaderboard.tsx
+++ b/src/pages/leaderboard.tsx
@@ -9,7 +9,7 @@ import {
TrainIcon,
BotOrNotIcon,
} from 'src/components/Common/Icons'
-import { getLeaderboard } from 'src/api'
+import { fetchLeaderboard } from 'src/api'
import { LeaderboardColumn, DelayedLoading } from 'src/components'
import { LeaderboardProvider } from 'src/components/Leaderboard/LeaderboardContext'
@@ -43,47 +43,45 @@ const Leaderboard: React.FC = () => {
return `${diffInDays} day${diffInDays > 1 ? 's' : ''} ago`
}
- const fetchLeaderboard = useCallback(async () => {
- setLoading(true)
- const lb = await getLeaderboard()
- setLastUpdated(new Date(lb.last_updated + 'Z'))
- setLeaderboard([
- {
- id: 'regular',
- icon: ,
- name: 'Regular',
- ranking: lb.play_leaders,
- },
- {
- id: 'puzzles',
- icon: ,
- name: 'Puzzles',
- ranking: lb.puzzles_leaders,
- },
- {
- id: 'turing',
- icon: ,
- name: 'Bot/Not',
- ranking: lb.turing_leaders,
- },
- {
- id: 'hand',
- icon: ,
- name: 'Hand',
- ranking: lb.hand_leaders,
- },
- {
- id: 'brain',
- icon: ,
- name: 'Brain',
- ranking: lb.brain_leaders,
- },
- ])
- setLoading(false)
- }, [])
-
useEffect(() => {
- fetchLeaderboard()
+ ;(async () => {
+ setLoading(true)
+ const lb = await fetchLeaderboard()
+ setLastUpdated(new Date(lb.last_updated + 'Z'))
+ setLeaderboard([
+ {
+ id: 'regular',
+ icon: ,
+ name: 'Regular',
+ ranking: lb.play_leaders,
+ },
+ {
+ id: 'puzzles',
+ icon: ,
+ name: 'Puzzles',
+ ranking: lb.puzzles_leaders,
+ },
+ {
+ id: 'turing',
+ icon: ,
+ name: 'Bot/Not',
+ ranking: lb.turing_leaders,
+ },
+ {
+ id: 'hand',
+ icon: ,
+ name: 'Hand',
+ ranking: lb.hand_leaders,
+ },
+ {
+ id: 'brain',
+ icon: ,
+ name: 'Brain',
+ ranking: lb.brain_leaders,
+ },
+ ])
+ setLoading(false)
+ })()
}, [fetchLeaderboard])
const containerVariants = {
diff --git a/src/pages/openings/index.tsx b/src/pages/openings/index.tsx
index 72c87502..9b764ffe 100644
--- a/src/pages/openings/index.tsx
+++ b/src/pages/openings/index.tsx
@@ -14,9 +14,9 @@ import {
MaiaEngineContext,
} from 'src/contexts'
import { DrillConfiguration, AnalyzedGame } from 'src/types'
-import { GameNode } from 'src/types/base/tree'
+import { GameNode } from 'src/types/tree'
+import openings from 'src/constants/openings.json'
import { MIN_STOCKFISH_DEPTH } from 'src/constants/analysis'
-import openings from 'src/lib/openings/openings.json'
import { OpeningDrillAnalysis } from 'src/components/Openings/OpeningDrillAnalysis'
import {
@@ -41,7 +41,7 @@ import {
getCurrentPlayer,
getAvailableMovesArray,
requiresPromotion,
-} from 'src/lib/train/utils'
+} from 'src/lib/puzzle'
const OpeningsPage: NextPage = () => {
const router = useRouter()
diff --git a/src/pages/play/hb.tsx b/src/pages/play/hb.tsx
index 0d0f1c15..7128156c 100644
--- a/src/pages/play/hb.tsx
+++ b/src/pages/play/hb.tsx
@@ -10,7 +10,7 @@ import { HandBrainPlayControls } from 'src/components/Play'
import { ModalContext, useTour } from 'src/contexts'
import { Color, PlayGameConfig, TimeControl } from 'src/types'
import { useHandBrainController } from 'src/hooks/usePlayController/useHandBrainController'
-import { PlayControllerContext } from 'src/contexts/PlayControllerContext/PlayControllerContext'
+import { PlayControllerContext } from 'src/contexts/PlayControllerContext'
import { tourConfigs } from 'src/constants/tours'
interface Props {
diff --git a/src/pages/play/maia.tsx b/src/pages/play/maia.tsx
index 12c66811..126f39ea 100644
--- a/src/pages/play/maia.tsx
+++ b/src/pages/play/maia.tsx
@@ -2,14 +2,14 @@ import Head from 'next/head'
import { startGame } from 'src/api'
import { NextPage } from 'next/types'
import { useRouter } from 'next/router'
+import { tourConfigs } from 'src/constants/tours'
import { ModalContext, useTour } from 'src/contexts'
-import { useContext, useEffect, useMemo, useState } from 'react'
import { DelayedLoading, PlayControls } from 'src/components'
import { Color, TimeControl, PlayGameConfig } from 'src/types'
+import { useContext, useEffect, useMemo, useState } from 'react'
import { GameplayInterface } from 'src/components/Board/GameplayInterface'
import { useVsMaiaPlayController } from 'src/hooks/usePlayController/useVsMaiaController'
-import { PlayControllerContext } from 'src/contexts/PlayControllerContext/PlayControllerContext'
-import { tourConfigs } from 'src/constants/tours'
+import { PlayControllerContext } from 'src/contexts/PlayControllerContext'
interface Props {
id: string
@@ -100,7 +100,6 @@ const PlayMaiaPage: NextPage = () => {
startFen,
} = router.query
- // simulateMaiaTime can be configured in setup modal, default to true if not specified
const [simulateMaiaTime, setSimulateMaiaTime] = useState(
simulateMaiaTimeQuery === 'true' || simulateMaiaTimeQuery === undefined
? true
@@ -132,7 +131,6 @@ const PlayMaiaPage: NextPage = () => {
useEffect(() => {
if (!initialTourCheck) {
setInitialTourCheck(true)
- // Always attempt to start the tour - the tour context will handle completion checking
startTour(tourConfigs.play.id, tourConfigs.play.steps, false)
}
}, [initialTourCheck, startTour])
diff --git a/src/pages/profile/[name].tsx b/src/pages/profile/[name].tsx
index 67e16cd3..7c93126f 100644
--- a/src/pages/profile/[name].tsx
+++ b/src/pages/profile/[name].tsx
@@ -5,7 +5,7 @@ import { useContext, useState, useEffect } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { PlayerStats } from 'src/types'
-import { getPlayerStats } from 'src/api'
+import { fetchPlayerStats } from 'src/api'
import { WindowSizeContext } from 'src/contexts'
import {
AuthenticatedWrapper,
@@ -64,7 +64,7 @@ const ProfilePage: NextPage = () => {
useEffect(() => {
const fetchStats = async (n: string) => {
setLoading(true)
- const playerStats = await getPlayerStats(n)
+ const playerStats = await fetchPlayerStats(n)
setName(n)
setStats(playerStats)
setLoading(false)
diff --git a/src/pages/profile/index.tsx b/src/pages/profile/index.tsx
index 369c44ac..15598f14 100644
--- a/src/pages/profile/index.tsx
+++ b/src/pages/profile/index.tsx
@@ -5,7 +5,7 @@ import { useContext, useState, useEffect } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { PlayerStats } from 'src/types'
-import { getPlayerStats } from 'src/api'
+import { fetchPlayerStats } from 'src/api'
import { AuthContext, WindowSizeContext } from 'src/contexts'
import {
AuthenticatedWrapper,
@@ -64,7 +64,7 @@ const ProfilePage: NextPage = () => {
useEffect(() => {
const fetchStats = async () => {
setLoading(true)
- const playerStats = await getPlayerStats()
+ const playerStats = await fetchPlayerStats()
setStats(playerStats)
setLoading(false)
}
diff --git a/src/pages/puzzles.tsx b/src/pages/puzzles.tsx
index 99eda2ef..bbe75550 100644
--- a/src/pages/puzzles.tsx
+++ b/src/pages/puzzles.tsx
@@ -18,9 +18,9 @@ import type { DrawShape } from 'chessground/draw'
import { Chess, PieceSymbol } from 'chess.ts'
import { AnimatePresence, motion } from 'framer-motion'
import {
- getTrainingGame,
+ fetchPuzzle,
logPuzzleGuesses,
- getTrainingPlayerStats,
+ fetchTrainingPlayerStats,
} from 'src/api'
import {
trackPuzzleStarted,
@@ -42,27 +42,24 @@ import {
Highlight,
MoveMap,
BlunderMeter,
- MovesByRating,
AnalysisSidebar,
} from 'src/components'
import { useTrainingController } from 'src/hooks/useTrainingController'
import { useAnalysisController } from 'src/hooks/useAnalysisController'
import { AllStats, useStats } from 'src/hooks/useStats'
-import { TrainingGame, Status } from 'src/types/training'
-import { MaiaEvaluation, StockfishEvaluation } from 'src/types'
-import { ModalContext, WindowSizeContext, useTour } from 'src/contexts'
+import { PuzzleGame, Status } from 'src/types/puzzle'
+import { AnalyzedGame, MaiaEvaluation, StockfishEvaluation } from 'src/types'
+import { WindowSizeContext, useTour } from 'src/contexts'
import { TrainingControllerContext } from 'src/contexts/TrainingControllerContext'
import {
- convertTrainingGameToAnalyzedGame,
getCurrentPlayer,
getAvailableMovesArray,
requiresPromotion,
-} from 'src/lib/train/utils'
-import { mockAnalysisData } from 'src/lib/analysis/mockAnalysisData'
+} from 'src/lib/puzzle'
import { tourConfigs } from 'src/constants/tours'
const statsLoader = async () => {
- const stats = await getTrainingPlayerStats()
+ const stats = await fetchTrainingPlayerStats()
return {
gamesPlayed: Math.max(0, stats.totalPuzzles),
gamesWon: stats.puzzlesSolved,
@@ -74,13 +71,13 @@ const TrainPage: NextPage = () => {
const router = useRouter()
const { startTour, tourState } = useTour()
- const [trainingGames, setTrainingGames] = useState([])
+ const [trainingGames, setTrainingGames] = useState([])
const [currentIndex, setCurrentIndex] = useState(0)
const [status, setStatus] = useState('default')
const [stats, incrementStats, updateRating] = useStats(statsLoader)
const [userGuesses, setUserGuesses] = useState([])
const [previousGameResults, setPreviousGameResults] = useState<
- (TrainingGame & { result?: boolean; ratingDiff?: number })[]
+ (PuzzleGame & { result?: boolean; ratingDiff?: number })[]
>([])
const [initialTourCheck, setInitialTourCheck] = useState(false)
const [loadingGame, setLoadingGame] = useState(false)
@@ -100,7 +97,7 @@ const TrainPage: NextPage = () => {
setLoadingGame(true)
let game
try {
- game = await getTrainingGame()
+ game = await fetchPuzzle()
} catch (e) {
router.push('/401')
return
@@ -269,7 +266,7 @@ const TrainPage: NextPage = () => {
}
interface Props {
- trainingGame: TrainingGame
+ trainingGame: PuzzleGame
gamesController: React.ReactNode
stats: AllStats
status: Status
@@ -301,7 +298,7 @@ const Train: React.FC = ({
const controller = useTrainingController(trainingGame)
const analyzedGame = useMemo(() => {
- return convertTrainingGameToAnalyzedGame(trainingGame)
+ return { ...trainingGame, type: 'play', availableMoves: [] } as AnalyzedGame
}, [trainingGame])
const analysisController = useAnalysisController(
@@ -438,7 +435,7 @@ const Train: React.FC = ({
analysisController.currentNode.mainChild,
)
} else {
- const newVariation = analyzedGame.tree.addVariation(
+ const newVariation = analyzedGame.tree.addVariationNode(
analysisController.currentNode,
newFen,
moveString,
@@ -528,7 +525,7 @@ const Train: React.FC = ({
analysisController.currentNode.mainChild,
)
} else {
- const newVariation = analyzedGame.tree.addVariation(
+ const newVariation = analyzedGame.tree.addVariationNode(
analysisController.currentNode,
newFen,
moveString,
@@ -639,7 +636,7 @@ const Train: React.FC = ({
if (analysisController.currentNode.mainChild?.move === moveString) {
analysisController.goToNode(analysisController.currentNode.mainChild)
} else {
- const newVariation = analyzedGame.tree.addVariation(
+ const newVariation = analyzedGame.tree.addVariationNode(
analysisController.currentNode,
newFen,
moveString,
diff --git a/src/providers/AnalysisListContextProvider/AnalysisListContextProvider.tsx b/src/providers/AnalysisListContextProvider/AnalysisListContextProvider.tsx
deleted file mode 100644
index 48404e52..00000000
--- a/src/providers/AnalysisListContextProvider/AnalysisListContextProvider.tsx
+++ /dev/null
@@ -1,126 +0,0 @@
-import { useRouter } from 'next/router'
-import { ReactNode, useContext, useEffect, useState } from 'react'
-
-import { AnalysisWebGame, AnalysisTournamentGame } from 'src/types'
-import { AuthContext, AnalysisListContext } from 'src/contexts'
-import { getAnalysisList, getLichessGames, getAnalysisGameList } from 'src/api'
-
-export const AnalysisListContextProvider: React.FC<{ children: ReactNode }> = ({
- children,
-}: {
- children: ReactNode
-}) => {
- const router = useRouter()
- const { user } = useContext(AuthContext)
-
- const [analysisTournamentList, setAnalysisTournamentList] = useState | null>(null)
- const [analysisLichessList, setAnalysisLichessList] = useState<
- AnalysisWebGame[]
- >([])
- const [analysisPlayList, setAnalysisPlayList] = useState(
- [],
- )
- const [analysisHandList, setAnalysisHandList] = useState(
- [],
- )
- const [analysisBrainList, setAnalysisBrainList] = useState(
- [],
- )
-
- useEffect(() => {
- async function getAndSetData() {
- let response
- try {
- response = await getAnalysisList()
- } catch (e) {
- router.push('/401')
- return
- }
-
- const newList = new Map(Object.entries(response))
- setAnalysisTournamentList(newList)
- }
-
- getAndSetData()
- }, [router])
-
- useEffect(() => {
- if (user?.lichessId) {
- getLichessGames(user?.lichessId, (data) => {
- const result = data.pgn.match(/\[Result\s+"(.+?)"\]/)[1] || '?'
-
- const game: AnalysisWebGame = {
- id: data.id,
- type: 'pgn',
- label: `${data.players.white.user?.id || 'Unknown'} vs. ${data.players.black.user?.id || 'Unknown'}`,
- result: result,
- pgn: data.pgn,
- }
-
- setAnalysisLichessList((x) => [...x, game])
- })
- }
- }, [user?.lichessId])
-
- useEffect(() => {
- if (user?.lichessId) {
- const playRequest = getAnalysisGameList('play', 1)
- const handRequest = getAnalysisGameList('hand', 1)
- const brainRequest = getAnalysisGameList('brain', 1)
-
- Promise.all([playRequest, handRequest, brainRequest]).then((data) => {
- const [play, hand, brain] = data
-
- const parse = (
- game: {
- game_id: string
- maia_name: string
- result: string
- player_color: 'white' | 'black'
- },
- type: string,
- ) => {
- const raw = game.maia_name.replace('_kdd_', ' ')
- const maia = raw.charAt(0).toUpperCase() + raw.slice(1)
-
- return {
- id: game.game_id,
- label:
- game.player_color === 'white'
- ? `You vs. ${maia}`
- : `${maia} vs. You`,
- result: game.result,
- type,
- }
- }
-
- setAnalysisPlayList(
- play.games.map((game: never) => parse(game, 'play')),
- )
- setAnalysisHandList(
- hand.games.map((game: never) => parse(game, 'hand')),
- )
- setAnalysisBrainList(
- brain.games.map((game: never) => parse(game, 'brain')),
- )
- })
- }
- }, [user?.lichessId])
-
- return (
-
- {children}
-
- )
-}
diff --git a/src/providers/AnalysisListContextProvider/index.ts b/src/providers/AnalysisListContextProvider/index.ts
deleted file mode 100644
index a1cbc015..00000000
--- a/src/providers/AnalysisListContextProvider/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from './AnalysisListContextProvider'
diff --git a/src/providers/AuthContextProvider/index.ts b/src/providers/AuthContextProvider/index.ts
deleted file mode 100644
index 338dd7f5..00000000
--- a/src/providers/AuthContextProvider/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from './AuthContextProvider'
diff --git a/src/providers/MaiaEngineContextProvider/index.ts b/src/providers/MaiaEngineContextProvider/index.ts
deleted file mode 100644
index f8370170..00000000
--- a/src/providers/MaiaEngineContextProvider/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { MaiaEngineContextProvider } from './MaiaEngineContextProvider'
diff --git a/src/providers/ModalContextProvider/index.ts b/src/providers/ModalContextProvider/index.ts
deleted file mode 100644
index 79658a5e..00000000
--- a/src/providers/ModalContextProvider/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from './ModalContextProvider'
diff --git a/src/providers/StockfishEngineContextProvider/index.ts b/src/providers/StockfishEngineContextProvider/index.ts
deleted file mode 100644
index e840d592..00000000
--- a/src/providers/StockfishEngineContextProvider/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { StockfishEngineContextProvider } from './StockfishEngineContextProvider'
diff --git a/src/providers/TourProvider/index.ts b/src/providers/TourProvider/index.ts
deleted file mode 100644
index c27d3ded..00000000
--- a/src/providers/TourProvider/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from './TourProvider'
diff --git a/src/providers/WindowSizeContextProvider/index.ts b/src/providers/WindowSizeContextProvider/index.ts
deleted file mode 100644
index 7fd1716d..00000000
--- a/src/providers/WindowSizeContextProvider/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from './WindowSizeContextProvider'
diff --git a/src/providers/index.ts b/src/providers/index.ts
deleted file mode 100644
index baf9d9a3..00000000
--- a/src/providers/index.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-export * from './WindowSizeContextProvider'
-export * from './ModalContextProvider'
-export * from './AuthContextProvider'
-export * from './AnalysisListContextProvider'
-export * from './MaiaEngineContextProvider'
-export * from './StockfishEngineContextProvider'
-export { SettingsProvider } from 'src/contexts/SettingsContext'
diff --git a/src/test-utils.tsx b/src/test-utils.tsx
deleted file mode 100644
index e75f05ed..00000000
--- a/src/test-utils.tsx
+++ /dev/null
@@ -1,197 +0,0 @@
-import React from 'react'
-import { render, type RenderOptions } from '@testing-library/react'
-import { AuthContextProvider } from 'src/providers/AuthContextProvider'
-import { ModalContextProvider } from 'src/providers/ModalContextProvider'
-import { WindowSizeContextProvider } from 'src/providers/WindowSizeContextProvider'
-
-// Mock user for testing
-export const mockUser = {
- id: 'test-user-123',
- username: 'testuser',
- email: 'test@example.com',
- displayName: 'Test User',
- avatar: null,
- isVerified: true,
- createdAt: '2023-01-01T00:00:00Z',
- updatedAt: '2023-01-01T00:00:00Z',
-}
-
-// Mock authentication context
-export const mockAuthContext = {
- user: mockUser,
- isAuthenticated: true,
- isLoading: false,
- login: jest.fn(),
- logout: jest.fn(),
- refreshUser: jest.fn(),
-}
-
-// Mock modal context
-export const mockModalContext = {
- openModal: jest.fn(),
- closeModal: jest.fn(),
- modalState: {
- isOpen: false,
- type: null,
- props: {},
- },
-}
-
-// Mock window size context
-export const mockWindowSizeContext = {
- windowSize: {
- width: 1024,
- height: 768,
- },
- isMobile: false,
- isTablet: false,
- isDesktop: true,
-}
-
-// Custom render function that includes providers
-const AllTheProviders: React.FC<{ children: React.ReactNode }> = ({
- children,
-}) => {
- return (
-
-
- {children}
-
-
- )
-}
-
-const customRender = (
- ui: React.ReactElement,
- options?: Omit,
-) => render(ui, { wrapper: AllTheProviders, ...options })
-
-// Re-export everything except render to avoid conflicts
-// Temporarily commented out due to testing library dependency issues
-// These are not needed for the main build
-/*
-export {
- screen,
- fireEvent,
- waitFor,
- act,
- cleanup,
- within,
- getByText,
- getByRole,
- getByLabelText,
- getByPlaceholderText,
- getByDisplayValue,
- getByAltText,
- getByTitle,
- getByTestId,
- queryByText,
- queryByRole,
- queryByLabelText,
- queryByPlaceholderText,
- queryByDisplayValue,
- queryByAltText,
- queryByTitle,
- queryByTestId,
- findByText,
- findByRole,
- findByLabelText,
- findByPlaceholderText,
- findByDisplayValue,
- findByAltText,
- findByTitle,
- findByTestId,
- getAllByText,
- getAllByRole,
- getAllByLabelText,
- getAllByPlaceholderText,
- getAllByDisplayValue,
- getAllByAltText,
- getAllByTitle,
- getAllByTestId,
- queryAllByText,
- queryAllByRole,
- queryAllByLabelText,
- queryAllByPlaceholderText,
- queryAllByDisplayValue,
- queryAllByAltText,
- queryAllByTitle,
- queryAllByTestId,
- findAllByText,
- findAllByRole,
- findAllByLabelText,
- findAllByPlaceholderText,
- findAllByDisplayValue,
- findAllByAltText,
- findAllByTitle,
- findAllByTestId,
-} from '@testing-library/react'
-*/
-export { customRender as render }
-
-// Test utilities for mocking chess engines
-export const mockStockfishEngine = {
- isReady: true,
- isAnalyzing: false,
- bestMove: null,
- evaluation: 0,
- pvLine: [],
- startAnalysis: jest.fn(),
- stopAnalysis: jest.fn(),
- makeMove: jest.fn(),
- setPosition: jest.fn(),
- setDepth: jest.fn(),
-}
-
-export const mockMaiaEngine = {
- isReady: true,
- isLoading: false,
- currentModel: 'maia-1500',
- availableModels: ['maia-1100', 'maia-1500', 'maia-1900'],
- loadModel: jest.fn(),
- predict: jest.fn().mockResolvedValue({
- bestMove: 'e2e4',
- confidence: 0.85,
- moveDistribution: [
- { move: 'e2e4', probability: 0.85 },
- { move: 'd2d4', probability: 0.1 },
- { move: 'g1f3', probability: 0.05 },
- ],
- }),
- setPosition: jest.fn(),
-}
-
-// Helper function to create mock chess positions
-export const createMockPosition = (
- fen = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
-) => ({
- fen,
- moves: [],
- isGameOver: false,
- isCheck: false,
- isCheckmate: false,
- isStalemate: false,
- turn: 'w',
- legalMoves: ['e2e4', 'd2d4', 'g1f3', 'b1c3'],
-})
-
-// Helper function to create mock analysis data
-export const createMockAnalysis = () => ({
- id: 'test-analysis-123',
- moves: [
- {
- move: 'e2e4',
- fen: 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1',
- stockfishEval: 0.2,
- maiaEval: 0.15,
- isBlunder: false,
- isGoodMove: true,
- annotations: [],
- },
- ],
- result: '1-0',
- date: '2023-01-01',
- white: 'White Player',
- black: 'Black Player',
- rating: { white: 1500, black: 1400 },
-})
diff --git a/src/types/analysis/index.ts b/src/types/analysis.ts
similarity index 54%
rename from src/types/analysis/index.ts
rename to src/types/analysis.ts
index 43e8f453..bca3140c 100644
--- a/src/types/analysis/index.ts
+++ b/src/types/analysis.ts
@@ -1,29 +1,23 @@
-import { Game } from '../base'
-import { AvailableMoves } from '../training'
-import Maia from 'src/providers/MaiaEngineContextProvider/model'
+import { Player } from './player'
+import { BaseGame } from './common'
+import { AvailableMoves } from './puzzle'
-export interface MaiaEngine {
- maia?: Maia
- status: MaiaStatus
- progress: number
- downloadModel: () => void
+export interface MoveValueMapping {
+ [move: string]: number
}
-export interface StockfishEngine {
- error: string | null
- status: StockfishStatus
- isReady: () => boolean
- stopEvaluation: () => void
- streamEvaluations: (
- fen: string,
- moveCount: number,
- depth?: number,
- ) => AsyncIterable | null
+export interface AnalyzedGame extends BaseGame {
+ availableMoves: AvailableMoves[]
+ type: EvaluationType
+ gameType: string
+ blackPlayer: Player
+ whitePlayer: Player
+ termination?: Termination
}
export interface MaiaEvaluation {
- policy: { [key: string]: number }
value: number
+ policy: { [key: string]: number }
}
export interface StockfishEvaluation {
@@ -37,21 +31,26 @@ export interface StockfishEvaluation {
winrate_loss_vec?: { [key: string]: number }
}
+export interface CachedEngineAnalysisEntry {
+ ply: number
+ fen: string
+ maia?: { [rating: string]: MaiaEvaluation }
+ stockfish?: {
+ depth: number
+ cp_vec: MoveValueMapping
+ }
+}
+
type EvaluationType =
| 'tournament'
- | 'pgn'
+ | 'lichess'
| 'play'
| 'hand'
| 'brain'
- | 'custom-pgn'
- | 'custom-fen'
+ | 'custom'
| 'stream'
-type StockfishEvaluations = T extends 'tournament'
- ? MoveMap[]
- : (StockfishEvaluation | undefined)[]
-
-export interface AnalysisTournamentGame {
+export interface WorldChampionshipGameListEntry {
game_index: number
event: string
site: string
@@ -62,41 +61,21 @@ export interface AnalysisTournamentGame {
result?: string
}
-export interface AnalysisWebGame {
+export interface MaiaGameListEntry {
id: string
type:
| 'tournament'
- | 'pgn'
+ | 'lichess'
| 'play'
| 'hand'
| 'brain'
- | 'custom-pgn'
- | 'custom-fen'
+ | 'custom'
| 'stream'
label: string
result: string
pgn?: string
}
-export interface AnalyzedGame extends Game {
- maiaEvaluations: { [rating: string]: MaiaEvaluation }[]
- stockfishEvaluations: StockfishEvaluations
- availableMoves: AvailableMoves[]
- type: EvaluationType
- pgn?: string
-}
-
-export interface LiveGame extends AnalyzedGame {
- loadedFen: string
- loaded: boolean
-}
-
-export interface CustomAnalysisInput {
- type: 'custom-pgn' | 'custom-fen'
- data: string // PGN string or FEN string
- name?: string
-}
-
export interface Termination {
result: string
winner: 'white' | 'black' | 'none' | undefined
@@ -104,15 +83,6 @@ export interface Termination {
condition?: string
}
-export interface MoveMap {
- [move: string]: number
-}
-
-export interface PositionEvaluation {
- trickiness: number
- performance: number
-}
-
export interface ColorSanMapping {
[move: string]: {
san: string
@@ -140,15 +110,6 @@ export interface BlunderMeterResult {
}
}
-export type MaiaStatus =
- | 'loading'
- | 'no-cache'
- | 'downloading'
- | 'ready'
- | 'error'
-
-export type StockfishStatus = 'loading' | 'ready' | 'error'
-
export interface MistakePosition {
nodeId: string
moveIndex: number
@@ -173,18 +134,3 @@ export interface LearnFromMistakesState {
maxAttempts: number
originalPosition: string | null // FEN of the position where the player should make a move
}
-
-// Streaming-related types
-export interface LiveGameData {
- gameId: string
- white: {
- name: string
- rating?: number
- }
- black: {
- name: string
- rating?: number
- }
- lastMoveFen?: string
- isLive: boolean
-}
diff --git a/src/types/auth/index.ts b/src/types/auth.ts
similarity index 100%
rename from src/types/auth/index.ts
rename to src/types/auth.ts
diff --git a/src/types/base/index.ts b/src/types/base/index.ts
deleted file mode 100644
index 2afc7ffb..00000000
--- a/src/types/base/index.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-import { Player } from '..'
-import { GameTree } from './tree'
-import { Termination } from '../analysis'
-
-export * from './tree'
-
-export type Check = false | 'white' | 'black'
-
-export interface Move {
- board: string
- lastMove?: [string, string]
- movePlayed?: [string, string]
- check?: false | 'white' | 'black'
- san?: string
- uci?: string
- maia_values?: { [key: string]: number }
-}
-
-export interface BaseGame {
- id: string
- moves: Move[]
- tree: GameTree
-}
-
-export interface Game extends BaseGame {
- gameType: string
- blackPlayer: Player
- whitePlayer: Player
- termination?: Termination
-}
-
-export interface DataNode {
- x: number
- y: number
- nx: number
- san?: string
- move: string
-}
-
-export type Color = 'white' | 'black'
-
-export type SetIndexFunction = (index: number) => void
diff --git a/src/types/blog/index.ts b/src/types/blog.ts
similarity index 100%
rename from src/types/blog/index.ts
rename to src/types/blog.ts
diff --git a/src/types/common.ts b/src/types/common.ts
new file mode 100644
index 00000000..15537dcc
--- /dev/null
+++ b/src/types/common.ts
@@ -0,0 +1,34 @@
+import { GameNode } from './node'
+import { GameTree } from './tree'
+import { Dispatch, SetStateAction } from 'react'
+
+export interface BaseGame {
+ id: string
+ tree: GameTree
+}
+
+export interface RawMove {
+ board: string
+ lastMove?: [string, string]
+ movePlayed?: [string, string]
+ check?: false | 'white' | 'black'
+ san?: string
+ uci?: string
+}
+
+export type Check = false | 'white' | 'black'
+
+export type Color = 'white' | 'black'
+
+export interface BaseTreeControllerContext {
+ gameTree: GameTree
+ currentNode: GameNode
+ setCurrentNode: Dispatch>
+ goToNode: (node: GameNode) => void
+ goToNextNode: () => void
+ goToPreviousNode: () => void
+ goToRootNode: () => void
+ plyCount: number
+ orientation: 'white' | 'black'
+ setOrientation: (orientation: 'white' | 'black') => void
+}
diff --git a/src/types/engine.ts b/src/types/engine.ts
new file mode 100644
index 00000000..bf77c853
--- /dev/null
+++ b/src/types/engine.ts
@@ -0,0 +1,30 @@
+import Maia from 'src/lib/engine/maia'
+import { StockfishEvaluation } from './analysis'
+
+export type MaiaStatus =
+ | 'loading'
+ | 'no-cache'
+ | 'downloading'
+ | 'ready'
+ | 'error'
+
+export type StockfishStatus = 'loading' | 'ready' | 'error'
+
+export interface MaiaEngine {
+ maia?: Maia
+ status: MaiaStatus
+ progress: number
+ downloadModel: () => void
+}
+
+export interface StockfishEngine {
+ error: string | null
+ status: StockfishStatus
+ isReady: () => boolean
+ stopEvaluation: () => void
+ streamEvaluations: (
+ fen: string,
+ moveCount: number,
+ depth?: number,
+ ) => AsyncIterable | null
+}
diff --git a/src/types/index.ts b/src/types/index.ts
index 525df138..560fd377 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -1,11 +1,15 @@
export * from './play'
+export * from './tree'
+export * from './node'
export * from './player'
export * from './analysis'
export * from './openings'
-export * from './base'
+export * from './common'
export * from './auth'
export * from './turing'
export * from './modal'
export * from './blog'
export * from './leaderboard'
export * from './stream'
+export * from './puzzle'
+export * from './engine'
diff --git a/src/types/modal/modal.ts b/src/types/modal.ts
similarity index 100%
rename from src/types/modal/modal.ts
rename to src/types/modal.ts
diff --git a/src/types/modal/index.ts b/src/types/modal/index.ts
deleted file mode 100644
index cdbd4fb0..00000000
--- a/src/types/modal/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from './modal'
diff --git a/src/types/base/tree.ts b/src/types/node.ts
similarity index 73%
rename from src/types/base/tree.ts
rename to src/types/node.ts
index 831f1dd2..717b6b3d 100644
--- a/src/types/base/tree.ts
+++ b/src/types/node.ts
@@ -1,5 +1,5 @@
-import { Chess, Color } from 'chess.ts'
-import { StockfishEvaluation, MaiaEvaluation } from '..'
+import { Color } from 'chess.ts'
+import { StockfishEvaluation, MaiaEvaluation } from '.'
import { MOVE_CLASSIFICATION_THRESHOLDS } from 'src/constants/analysis'
import { calculateMoveColor } from 'src/hooks/useAnalysisController/utils'
@@ -8,150 +8,6 @@ interface NodeAnalysis {
stockfish?: StockfishEvaluation
}
-export class GameTree {
- private root: GameNode
- private headers: Map
-
- constructor(initialFen: string) {
- this.root = new GameNode(initialFen)
- this.headers = new Map()
-
- if (
- initialFen !== 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'
- ) {
- this.headers.set('SetUp', '1')
- this.headers.set('FEN', initialFen)
- }
- }
-
- setHeader(key: string, value: string): void {
- this.headers.set(key, value)
- }
-
- getHeader(key: string): string | undefined {
- return this.headers.get(key)
- }
-
- toPGN(): string {
- const chess = this.toChess()
- return chess.pgn()
- }
-
- toChess(): Chess {
- const chess = new Chess()
-
- if (this.root.fen !== chess.fen()) {
- chess.load(this.root.fen)
- }
-
- this.headers.forEach((value, key) => {
- chess.addHeader(key, value)
- })
-
- let complete = false
- let node = this.root
- while (!complete) {
- if (node.mainChild) {
- node = node.mainChild
- chess.move(node.san || node.move || '')
- } else {
- complete = true
- }
- }
-
- return chess
- }
-
- getRoot(): GameNode {
- return this.root
- }
-
- getMainLine(): GameNode[] {
- return this.root.getMainLine()
- }
-
- addMainMove(
- node: GameNode,
- fen: string,
- move: string,
- san: string,
- activeModel?: string,
- time?: number,
- ): GameNode {
- return node.addChild(fen, move, san, true, activeModel, time)
- }
-
- addVariation(
- node: GameNode,
- fen: string,
- move: string,
- san: string,
- activeModel?: string,
- time?: number,
- ): GameNode {
- if (node.findVariation(move)) {
- return node.findVariation(move) as GameNode
- }
- return node.addChild(fen, move, san, false, activeModel, time)
- }
-
- toMoveArray(): string[] {
- const moves: string[] = []
- let node = this.root
- while (node.mainChild) {
- node = node.mainChild
- if (node.move) moves.push(node.move)
- }
- return moves
- }
-
- toTimeArray(): number[] {
- const times: number[] = []
- let node = this.root
- while (node.mainChild) {
- node = node.mainChild
- times.push(node.time || 0)
- }
- return times
- }
-
- addMoveToMainLine(moveUci: string, time?: number): GameNode | null {
- const mainLine = this.getMainLine()
- const lastNode = mainLine[mainLine.length - 1]
-
- const chess = new Chess(lastNode.fen)
- const result = chess.move(moveUci, { sloppy: true })
-
- if (result) {
- return this.addMainMove(
- lastNode,
- chess.fen(),
- moveUci,
- result.san,
- undefined,
- time,
- )
- }
-
- return null
- }
-
- addMovesToMainLine(moves: string[], times?: number[]): GameNode | null {
- let currentNode: GameNode | null = null
-
- for (let i = 0; i < moves.length; i++) {
- const move = moves[i]
- const time = times?.[i]
- currentNode = this.addMoveToMainLine(move, time)
- if (!currentNode) {
- return null
- }
- }
-
- return currentNode
- }
-}
-
export class GameNode {
private _fen: string
private _move: string | null
@@ -260,7 +116,6 @@ export class GameNode {
return parseInt(parts[5]) - (turn === 'w' ? 1 : 0)
}
- // Core classification logic - used by both instance and static methods
private performMoveClassification(
stockfishEval: StockfishEvaluation,
maiaEval: { [rating: string]: MaiaEvaluation } | undefined,
@@ -424,7 +279,6 @@ export class GameNode {
this._analysis.stockfish,
activeModel,
)
- // Set color for the child based on current analysis
child._color = calculateMoveColor(this._analysis.stockfish, move)
}
@@ -437,7 +291,6 @@ export class GameNode {
): void {
this._analysis.maia = maiaEval
- // Re-classify all children now that we have Maia data
if (this._analysis.stockfish && this._analysis.stockfish.depth >= 12) {
for (const child of this._children) {
if (child.move) {
@@ -523,19 +376,6 @@ export class GameNode {
this._mainChild = null
}
- promoteVariation(move: string): boolean {
- const variation = this.findVariation(move)
- if (!variation) return false
-
- if (this._mainChild) {
- this._mainChild._mainline = false
- }
-
- this._mainChild = variation
- variation._mainline = true
- return true
- }
-
setTime(time: number): void {
this._time = time
}
diff --git a/src/types/openings/index.ts b/src/types/openings.ts
similarity index 98%
rename from src/types/openings/index.ts
rename to src/types/openings.ts
index 69ce9d5f..8d3713b7 100644
--- a/src/types/openings/index.ts
+++ b/src/types/openings.ts
@@ -1,4 +1,4 @@
-import { GameTree, GameNode } from '../base'
+import { GameTree, GameNode } from './common'
export interface Opening {
id: string
diff --git a/src/types/play/play.ts b/src/types/play.ts
similarity index 82%
rename from src/types/play/play.ts
rename to src/types/play.ts
index 9d4fc4fe..b0bb22b6 100644
--- a/src/types/play/play.ts
+++ b/src/types/play.ts
@@ -1,5 +1,5 @@
-import { Termination } from '../analysis'
-import { BaseGame, Color, GameTree } from '../base'
+import { Termination } from './analysis'
+import { BaseGame, Color } from './common'
export const TimeControlOptions = ['3+0', '5+2', '10+0', '15+10', 'unlimited']
export const TimeControlOptionNames = [
@@ -23,7 +23,7 @@ export interface PlayGameConfig {
playType: PlayType
isBrain: boolean
sampleMoves: boolean
- simulateMaiaTime?: boolean // Made optional since it's now managed in-game
+ simulateMaiaTime?: boolean
startFen?: string
maiaPartnerVersion?: string
}
diff --git a/src/types/play/index.ts b/src/types/play/index.ts
deleted file mode 100644
index 1cf1ab44..00000000
--- a/src/types/play/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from './play'
diff --git a/src/types/player/index.ts b/src/types/player.ts
similarity index 100%
rename from src/types/player/index.ts
rename to src/types/player.ts
diff --git a/src/types/puzzle.ts b/src/types/puzzle.ts
new file mode 100644
index 00000000..d6a218ae
--- /dev/null
+++ b/src/types/puzzle.ts
@@ -0,0 +1,34 @@
+import {
+ Termination,
+ Player,
+ RawMove,
+ MoveValueMapping,
+ GameTree,
+ BaseGame,
+} from '.'
+
+export interface AvailableMoves {
+ [uci: string]: RawMove
+}
+
+export interface PuzzleGame extends BaseGame {
+ puzzle_elo: number
+ gameType: string
+ blackPlayer: Player
+ whitePlayer: Player
+ termination?: Termination
+ stockfishEvaluation: MoveValueMapping
+ maiaEvaluation: MoveValueMapping
+ availableMoves: AvailableMoves
+ targetIndex: number
+ result?: boolean
+ tree: GameTree
+}
+
+export type Status =
+ | 'default'
+ | 'loading'
+ | 'forfeit'
+ | 'correct'
+ | 'incorrect'
+ | 'archived'
diff --git a/src/types/stream/index.ts b/src/types/stream.ts
similarity index 70%
rename from src/types/stream/index.ts
rename to src/types/stream.ts
index ac9831c5..963fe598 100644
--- a/src/types/stream/index.ts
+++ b/src/types/stream.ts
@@ -1,3 +1,24 @@
+import { AnalyzedGame } from './analysis'
+
+export interface LiveGame extends AnalyzedGame {
+ loadedFen: string
+ loaded: boolean
+}
+
+export interface LiveGameData {
+ gameId: string
+ white: {
+ name: string
+ rating?: number
+ }
+ black: {
+ name: string
+ rating?: number
+ }
+ lastMoveFen?: string
+ isLive: boolean
+}
+
export interface StreamedGame {
id: string
fen: string
diff --git a/src/types/training/index.ts b/src/types/training/index.ts
deleted file mode 100644
index f00d46f8..00000000
--- a/src/types/training/index.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { Game, Move, MoveMap, GameTree } from '..'
-
-export interface AvailableMoves {
- [fromTo: string]: Move
-}
-
-export interface TrainingGame extends Game {
- puzzle_elo: number
- stockfishEvaluation: MoveMap
- maiaEvaluation: MoveMap
- availableMoves: AvailableMoves
- targetIndex: number
- result?: boolean
- tree: GameTree
-}
-
-export type Status =
- | 'default'
- | 'loading'
- | 'forfeit'
- | 'correct'
- | 'incorrect'
- | 'archived'
diff --git a/src/types/tree.ts b/src/types/tree.ts
new file mode 100644
index 00000000..3b54c2c7
--- /dev/null
+++ b/src/types/tree.ts
@@ -0,0 +1,151 @@
+import { Chess } from 'chess.ts'
+import { GameNode } from './node'
+
+export class GameTree {
+ private root: GameNode
+ private headers: Map
+
+ constructor(initialFen: string) {
+ this.root = new GameNode(initialFen)
+ this.headers = new Map()
+
+ if (
+ initialFen !== 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'
+ ) {
+ this.headers.set('SetUp', '1')
+ this.headers.set('FEN', initialFen)
+ }
+ }
+
+ setHeader(key: string, value: string): void {
+ this.headers.set(key, value)
+ }
+
+ getHeader(key: string): string | undefined {
+ return this.headers.get(key)
+ }
+
+ toPGN(): string {
+ const chess = this.toChess()
+ return chess.pgn()
+ }
+
+ toChess(): Chess {
+ const chess = new Chess()
+
+ if (this.root.fen !== chess.fen()) {
+ chess.load(this.root.fen)
+ }
+
+ this.headers.forEach((value, key) => {
+ chess.addHeader(key, value)
+ })
+
+ let complete = false
+ let node = this.root
+ while (!complete) {
+ if (node.mainChild) {
+ node = node.mainChild
+ chess.move(node.san || node.move || '')
+ } else {
+ complete = true
+ }
+ }
+
+ return chess
+ }
+
+ getRoot(): GameNode {
+ return this.root
+ }
+
+ getLastMainlineNode(): GameNode {
+ const mainLine = this.root.getMainLine()
+ return mainLine[mainLine.length - 1]
+ }
+
+ getMainLine(): GameNode[] {
+ return this.root.getMainLine()
+ }
+
+ addMainlineNode(
+ node: GameNode,
+ fen: string,
+ move: string,
+ san: string,
+ activeModel?: string,
+ time?: number,
+ ): GameNode {
+ return node.addChild(fen, move, san, true, activeModel, time)
+ }
+
+ addVariationNode(
+ node: GameNode,
+ fen: string,
+ move: string,
+ san: string,
+ activeModel?: string,
+ time?: number,
+ ): GameNode {
+ if (node.findVariation(move)) {
+ return node.findVariation(move) as GameNode
+ }
+ return node.addChild(fen, move, san, false, activeModel, time)
+ }
+
+ toMoveArray(): string[] {
+ const moves: string[] = []
+ let node = this.root
+ while (node.mainChild) {
+ node = node.mainChild
+ if (node.move) moves.push(node.move)
+ }
+ return moves
+ }
+
+ toTimeArray(): number[] {
+ const times: number[] = []
+ let node = this.root
+ while (node.mainChild) {
+ node = node.mainChild
+ times.push(node.time || 0)
+ }
+ return times
+ }
+
+ addMoveToMainLine(moveUci: string, time?: number): GameNode | null {
+ const mainLine = this.getMainLine()
+ const lastNode = mainLine[mainLine.length - 1]
+
+ const board = new Chess(lastNode.fen)
+ const result = board.move(moveUci, { sloppy: true })
+
+ if (result) {
+ return this.addMainlineNode(
+ lastNode,
+ board.fen(),
+ moveUci,
+ result.san,
+ undefined,
+ time,
+ )
+ }
+
+ return null
+ }
+
+ addMovesToMainLine(moves: string[], times?: number[]): GameNode | null {
+ let currentNode: GameNode | null = null
+
+ for (let i = 0; i < moves.length; i++) {
+ const move = moves[i]
+ const time = times?.[i]
+ currentNode = this.addMoveToMainLine(move, time)
+ if (!currentNode) {
+ return null
+ }
+ }
+
+ return currentNode
+ }
+}
diff --git a/src/types/turing/index.ts b/src/types/turing.ts
similarity index 77%
rename from src/types/turing/index.ts
rename to src/types/turing.ts
index 697d64ca..2a3d6817 100644
--- a/src/types/turing/index.ts
+++ b/src/types/turing.ts
@@ -1,5 +1,5 @@
-import { Termination, Color, Player, GameTree } from '..'
-import { BaseGame } from '../base'
+import { BaseGame } from './common'
+import { Termination, Color, Player } from '.'
export interface TuringGame extends BaseGame {
termination: Termination