From 47c37487b34c0311501102f9a55a694899e344d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Dec 2025 21:30:28 +0000 Subject: [PATCH] refactor: code review improvements - Add 30s timeout to Stockfish evaluation to prevent hanging promises - Add FEN validation and depth cap (max 30) to Stockfish API route - Create ErrorBoundary component with specialized fallbacks for chess game and tutor - Extract useChessSounds hook for better audio management - Add React.memo to EvaluationBar and CapturedPieces for performance - Add useMemo to CapturedPieces for sorted pieces calculation - Improve tacticDetection to return empty array instead of "none" type - Add filterMeaningfulTactics and hasTactics helper functions - Translate hardcoded UI strings (stockfishLevel, download, evalChange) - Update translations for EN, DE, FR, IT, PL - Add uuid to Jest transformIgnorePatterns for ESM compatibility - Update tests to use new filterMeaningfulTactics function --- jest.config.ts | 2 +- src/app/analysis/__tests__/page.test.tsx | 5 + src/app/analysis/page.tsx | 7 +- src/app/api/v1/stockfish/route.ts | 24 +++- src/components/CapturedPieces.tsx | 20 ++- src/components/ChessGame.tsx | 45 ++---- src/components/ErrorBoundary.tsx | 131 ++++++++++++++++++ src/components/EvaluationBar.tsx | 9 +- src/components/GameOverModal.tsx | 5 +- src/components/Tutor.tsx | 4 +- .../__tests__/OpeningTrainingContext.test.tsx | 5 + src/lib/__tests__/tacticDetection.test.ts | 6 +- src/lib/hooks/index.ts | 2 + src/lib/hooks/useChessSounds.ts | 109 +++++++++++++++ src/lib/i18n/translations.ts | 18 +++ src/lib/stockfish.ts | 32 ++++- src/lib/tacticDetection.ts | 22 ++- 17 files changed, 385 insertions(+), 61 deletions(-) create mode 100644 src/components/ErrorBoundary.tsx create mode 100644 src/lib/hooks/index.ts create mode 100644 src/lib/hooks/useChessSounds.ts diff --git a/jest.config.ts b/jest.config.ts index 0800b07..5bbcfdc 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -20,7 +20,7 @@ const config: Config = { '/e2e/', // Exclude Playwright e2e tests ], transformIgnorePatterns: [ - 'node_modules/(?!(react-markdown|remark-.*|unified|bail|is-plain-obj|trough|vfile|unist-.*|mdast-.*|micromark.*|decode-named-character-reference|character-entities|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|ccount|escape-string-regexp|markdown-table)/)', + 'node_modules/(?!(react-markdown|remark-.*|unified|bail|is-plain-obj|trough|vfile|unist-.*|mdast-.*|micromark.*|decode-named-character-reference|character-entities|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|ccount|escape-string-regexp|markdown-table|uuid)/)', ], } diff --git a/src/app/analysis/__tests__/page.test.tsx b/src/app/analysis/__tests__/page.test.tsx index 19db74c..ca74336 100644 --- a/src/app/analysis/__tests__/page.test.tsx +++ b/src/app/analysis/__tests__/page.test.tsx @@ -31,10 +31,15 @@ jest.mock("@/lib/stockfish", () => { jest.mock("@/lib/tacticDetection", () => { const detectMissedTactics = jest.fn(); const uciToSan = jest.fn(); + const filterMeaningfulTactics = jest.fn((tactics) => { + if (!tactics) return []; + return tactics.filter((t: { tactic_type: string }) => t.tactic_type !== "none"); + }); return { __esModule: true, detectMissedTactics, uciToSan, + filterMeaningfulTactics, __mock: { detectMissedTactics, uciToSan }, }; }); diff --git a/src/app/analysis/page.tsx b/src/app/analysis/page.tsx index a3efe01..0c89503 100644 --- a/src/app/analysis/page.tsx +++ b/src/app/analysis/page.tsx @@ -12,7 +12,7 @@ import { useTranslation } from "@/lib/i18n/useTranslation"; import { Personality, PERSONALITIES } from "@/lib/personalities"; import { Stockfish, StockfishEvaluation } from "@/lib/stockfish"; import { detectChessFormat, ChessFormat } from "@/lib/chessFormatDetector"; -import { detectMissedTactics, DetectedTactic, uciToSan } from "@/lib/tacticDetection"; +import { detectMissedTactics, DetectedTactic, uciToSan, filterMeaningfulTactics } from "@/lib/tacticDetection"; import { lookupPossibleOpenings, buildMoveSequenceFromSteps, OpeningMetadata } from "@/lib/openings"; import { getGenAIModel } from "@/lib/gemini"; import { ChatSession } from "@google/generative-ai"; @@ -326,8 +326,7 @@ IMPORTANT: const evalBefore = details.evalBefore!.score / 100; const evalAfter = details.evalAfter!.score / 100; const mateInfo = details.evalAfter!.mate !== null ? `Mate in ${details.evalAfter!.mate}` : "No mate detected"; - const tactics = (details.missedTactics || []) - .filter(t => t.tactic_type !== "none") + const tactics = filterMeaningfulTactics(details.missedTactics) .map(t => `${t.tactic_type}${t.material_delta ? ` (~${(t.material_delta / 100).toFixed(1)} pawns)` : ""}`) .join("; ") || "None"; @@ -406,7 +405,7 @@ INSTRUCTIONS: }; const currentDetails = currentIndex > 0 ? stepDetails[currentIndex] : undefined; - const tacticSummary = (currentDetails?.missedTactics || []).filter(t => t.tactic_type !== "none"); + const tacticSummary = filterMeaningfulTactics(currentDetails?.missedTactics); return (
diff --git a/src/app/api/v1/stockfish/route.ts b/src/app/api/v1/stockfish/route.ts index 9952cb0..c02a35c 100644 --- a/src/app/api/v1/stockfish/route.ts +++ b/src/app/api/v1/stockfish/route.ts @@ -1,9 +1,22 @@ import { NextRequest, NextResponse } from "next/server"; import { evaluateStockfish } from "@/lib/server/stockfishEngine"; import { StockfishEvaluation } from "@/lib/stockfish"; +import { Chess } from "chess.js"; export const runtime = "nodejs"; +/** + * Validates a FEN string by attempting to create a Chess instance + */ +function isValidFEN(fen: string): boolean { + try { + new Chess(fen); + return true; + } catch { + return false; + } +} + export async function POST(request: NextRequest) { try { const body = await request.json(); @@ -13,6 +26,11 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "Missing or invalid FEN" }, { status: 400 }); } + // Validate FEN string format and chess position validity + if (!isValidFEN(fen)) { + return NextResponse.json({ error: "Invalid FEN: position is not a valid chess position" }, { status: 400 }); + } + const parsedDepth = Number(depth); const parsedMultiPV = Number(multiPV); @@ -20,11 +38,15 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "Depth must be a positive number" }, { status: 400 }); } + // Cap depth to prevent excessive computation + const maxDepth = 30; + const safeDepth = Math.min(parsedDepth, maxDepth); + if (!Number.isFinite(parsedMultiPV) || parsedMultiPV <= 0) { return NextResponse.json({ error: "multiPV must be a positive number" }, { status: 400 }); } - const evaluation: StockfishEvaluation = await evaluateStockfish(fen, parsedDepth, parsedMultiPV); + const evaluation: StockfishEvaluation = await evaluateStockfish(fen, safeDepth, parsedMultiPV); return NextResponse.json({ evaluation }); } catch (error) { console.error("Stockfish API error", error); diff --git a/src/components/CapturedPieces.tsx b/src/components/CapturedPieces.tsx index c403e9c..a8d4b90 100644 --- a/src/components/CapturedPieces.tsx +++ b/src/components/CapturedPieces.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { memo, useMemo } from 'react'; interface CapturedPiecesProps { captured: string[]; // Array of piece types, e.g., ['p', 'n', 'q'] @@ -15,10 +15,18 @@ const PIECE_ICONS: Record = { 'k': '♚', // King is never captured, but for completeness }; -export const CapturedPieces: React.FC = ({ captured, color, score }) => { - // Sort pieces by value for better display: Q, R, B, N, P - const sortOrder = ['q', 'r', 'b', 'n', 'p']; - const sortedPieces = [...captured].sort((a, b) => sortOrder.indexOf(a) - sortOrder.indexOf(b)); +const sortOrder = ['q', 'r', 'b', 'n', 'p']; + +/** + * Displays captured pieces with optional material advantage score. + * Memoized to prevent unnecessary re-renders. + */ +export const CapturedPieces = memo(function CapturedPieces({ captured, color, score }: CapturedPiecesProps) { + // Memoize sorted pieces to prevent recalculation on every render + const sortedPieces = useMemo( + () => [...captured].sort((a, b) => sortOrder.indexOf(a) - sortOrder.indexOf(b)), + [captured] + ); return (
@@ -36,4 +44,4 @@ export const CapturedPieces: React.FC = ({ captured, color, )}
); -}; +}); diff --git a/src/components/ChessGame.tsx b/src/components/ChessGame.tsx index 9601b87..36748a0 100644 --- a/src/components/ChessGame.tsx +++ b/src/components/ChessGame.tsx @@ -17,6 +17,7 @@ import { Brain, ArrowLeft, Download, Flag, AlertTriangle, X } from "lucide-react import { CapturedPieces } from "./CapturedPieces"; import { detectMissedTactics, uciToSan, DetectedTactic } from "@/lib/tacticDetection"; import { upsertSavedGame } from "@/lib/savedGames"; +import { useChessSounds } from "@/lib/hooks/useChessSounds"; interface ChessGameProps { gameId: string; @@ -87,28 +88,8 @@ export default function ChessGame({ gameId, initialFen, initialPgn, initialPerso const messagesEndRef = useRef(null); const hasRebuiltHistoryRef = useRef(false); - // Sound Refs - const moveSound = useRef(null); - const captureSound = useRef(null); - const checkSound = useRef(null); - const victorySound = useRef(null); - const defeatSound = useRef(null); - - useEffect(() => { - moveSound.current = new Audio('/sounds/move.wav'); - captureSound.current = new Audio('/sounds/capture.wav'); - checkSound.current = new Audio('/sounds/check.wav'); - victorySound.current = new Audio('/sounds/victory.wav'); - defeatSound.current = new Audio('/sounds/defeat.wav'); - }, []); - - const playMoveSound = (captured: boolean) => { - if (captured) { - captureSound.current?.play().catch(e => console.error("Audio play failed", e)); - } else { - moveSound.current?.play().catch(e => console.error("Audio play failed", e)); - } - }; + // Chess sounds hook + const { playMoveSound, playCheck, playVictory, playDefeat } = useChessSounds(); // Removed auto-scroll to prevent page jumping when moves are added // Users can manually scroll to see move history if needed @@ -334,13 +315,13 @@ export default function ChessGame({ gameId, initialFen, initialPgn, initialPerso if (game.turn() === 'w') { result = "Checkmate! You lost."; winner = "Black"; - if (playerColor === 'white') defeatSound.current?.play().catch(e => console.error(e)); - else victorySound.current?.play().catch(e => console.error(e)); + if (playerColor === 'white') playDefeat(); + else playVictory(); } else { result = "Checkmate! You won!"; winner = "White"; - if (playerColor === 'white') victorySound.current?.play().catch(e => console.error(e)); - else defeatSound.current?.play().catch(e => console.error(e)); + if (playerColor === 'white') playVictory(); + else playDefeat(); } } else if (game.isDraw()) { result = "Draw!"; @@ -349,12 +330,12 @@ export default function ChessGame({ gameId, initialFen, initialPgn, initialPerso result = "Stalemate!"; winner = "Draw"; } else if (game.inCheck()) { - checkSound.current?.play().catch(e => console.error(e)); + playCheck(); } setGameOverState({ result, winner }); } - }, [fen, playerColor]); + }, [fen, playerColor, playDefeat, playVictory, playCheck]); // Pre-Analysis (P0) useEffect(() => { @@ -764,12 +745,12 @@ export default function ChessGame({ gameId, initialFen, initialPgn, initialPerso onClick={() => setShowStrengthSlider(!showStrengthSlider)} className="hover:text-gray-700 dark:hover:text-gray-200 underline decoration-dotted underline-offset-2" > - Stockfish Level: {stockfishDepth} + {t.game.stockfishLevel}: {stockfishDepth} {showStrengthSlider && (
setShowDownloadModal(true)} className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded hover:bg-green-200 dark:bg-green-900 dark:text-green-200 flex items-center gap-1" > - Download + {t.game.download} +
+ ); + } + + return this.props.children; + } +} + +/** + * Specialized Error Boundary for the Chess Game component + */ +export function ChessGameErrorFallback({ onRetry }: { onRetry?: () => void }) { + return ( +
+ +

+ Chess Engine Error +

+

+ There was a problem loading the chess engine. This might be due to a browser + compatibility issue or network problem. +

+
+ + {onRetry && ( + + )} +
+
+ ); +} + +/** + * Specialized Error Boundary for the Tutor/Chat component + */ +export function TutorErrorFallback({ onRetry }: { onRetry?: () => void }) { + return ( +
+ +

+ Tutor Unavailable +

+

+ The AI tutor encountered an error. You can continue playing without assistance. +

+ {onRetry && ( + + )} +
+ ); +} + +export default ErrorBoundary; diff --git a/src/components/EvaluationBar.tsx b/src/components/EvaluationBar.tsx index 1ab5b95..8d92d49 100644 --- a/src/components/EvaluationBar.tsx +++ b/src/components/EvaluationBar.tsx @@ -1,5 +1,6 @@ "use client"; +import { memo } from "react"; import clsx from "clsx"; interface EvaluationBarProps { @@ -9,7 +10,11 @@ interface EvaluationBarProps { orientation?: 'vertical' | 'horizontal'; } -export function EvaluationBar({ score, mate, isPlayerWhite, orientation = 'vertical' }: EvaluationBarProps) { +/** + * Visual evaluation bar showing the current position advantage. + * Memoized to prevent unnecessary re-renders when props haven't changed. + */ +export const EvaluationBar = memo(function EvaluationBar({ score, mate, isPlayerWhite, orientation = 'vertical' }: EvaluationBarProps) { // Calculate white's percentage height/width // Using sigmoid-like function for score: P = 1 / (1 + 10^(-score/400)) // This is a standard way to visualize CP advantage. @@ -78,4 +83,4 @@ export function EvaluationBar({ score, mate, isPlayerWhite, orientation = 'verti
); -} +}); diff --git a/src/components/GameOverModal.tsx b/src/components/GameOverModal.tsx index 0cb59b4..58b20b6 100644 --- a/src/components/GameOverModal.tsx +++ b/src/components/GameOverModal.tsx @@ -4,7 +4,7 @@ import { useState, useEffect, useRef } from "react"; import { getGenAIModel } from "@/lib/gemini"; import { Loader2, X, Trophy, AlertTriangle, RefreshCw } from "lucide-react"; import { StockfishEvaluation } from "@/lib/stockfish"; -import { DetectedTactic } from "@/lib/tacticDetection"; +import { DetectedTactic, filterMeaningfulTactics } from "@/lib/tacticDetection"; import ReactMarkdown from "react-markdown"; import { SupportedLanguage } from "@/lib/i18n/translations"; @@ -145,8 +145,7 @@ export function GameOverModal({ result, winner, history, apiKey, language, onClo const inaccuracies = detectedMistakes.filter(m => m.category === 'inaccuracy'); const describeTactics = (tactics?: DetectedTactic[]) => { - if (!tactics || tactics.length === 0) return ""; - const meaningful = tactics.filter(t => t.tactic_type !== 'none'); + const meaningful = filterMeaningfulTactics(tactics); if (meaningful.length === 0) return ""; return meaningful.map(t => { const material = t.material_delta ? ` (~${t.material_delta}cp)` : ''; diff --git a/src/components/Tutor.tsx b/src/components/Tutor.tsx index 1da9362..f57df76 100644 --- a/src/components/Tutor.tsx +++ b/src/components/Tutor.tsx @@ -14,7 +14,7 @@ import ReactMarkdown from "react-markdown"; import { useTranslation } from '@/lib/i18n/useTranslation'; import { SupportedLanguage } from '@/lib/i18n/translations'; -import { DetectedTactic } from '@/lib/tacticDetection'; +import { DetectedTactic, filterMeaningfulTactics } from '@/lib/tacticDetection'; import { useDebug } from '@/contexts/DebugContext'; import { MoveHistoryItem } from './GameOverModal'; import { parseGeminiError, GeminiErrorInfo, isGeminiError } from '@/lib/geminiErrorHandler'; @@ -582,7 +582,7 @@ INSTRUCTIONS: // Tactical Analysis Instruction let tacticalInstruction = ""; if (missedTactics && missedTactics.length > 0) { - const meaningfulTactics = missedTactics.filter(t => t.tactic_type !== 'none'); + const meaningfulTactics = filterMeaningfulTactics(missedTactics); if (meaningfulTactics.length > 0) { const tacticDescriptions = meaningfulTactics.map(t => { let desc = `- ${t.tactic_type.toUpperCase()}`; diff --git a/src/contexts/__tests__/OpeningTrainingContext.test.tsx b/src/contexts/__tests__/OpeningTrainingContext.test.tsx index bc4082f..b7da09b 100644 --- a/src/contexts/__tests__/OpeningTrainingContext.test.tsx +++ b/src/contexts/__tests__/OpeningTrainingContext.test.tsx @@ -10,6 +10,11 @@ * This is Phase 5 of the refactoring plan - comprehensive testing. */ +// Mock uuid before any imports +jest.mock('uuid', () => ({ + v4: jest.fn(() => 'test-uuid-1234'), +})); + import React from 'react'; import { renderHook, act, waitFor } from '@testing-library/react'; import { OpeningTrainingProvider, useOpeningTraining } from '../OpeningTrainingContext'; diff --git a/src/lib/__tests__/tacticDetection.test.ts b/src/lib/__tests__/tacticDetection.test.ts index 97ee8b3..63b2f2f 100644 --- a/src/lib/__tests__/tacticDetection.test.ts +++ b/src/lib/__tests__/tacticDetection.test.ts @@ -170,7 +170,7 @@ describe('tacticDetection', () => { expect(result).toEqual([]); }); - it('should return "none" tactic if no specific tactics found', () => { + it('should return empty array if no specific tactics found', () => { const fen = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'; const result = detectMissedTactics({ fen, @@ -180,8 +180,8 @@ describe('tacticDetection', () => { cpLoss: 60, }); - expect(result.length).toBe(1); - expect(result[0].tactic_type).toBe('none'); + // Returns empty array when no specific tactics detected + expect(result).toEqual([]); }); it('should handle custom evalLossThreshold', () => { diff --git a/src/lib/hooks/index.ts b/src/lib/hooks/index.ts new file mode 100644 index 0000000..39d2ebd --- /dev/null +++ b/src/lib/hooks/index.ts @@ -0,0 +1,2 @@ +export { useChessSounds } from "./useChessSounds"; +export type { ChessSounds } from "./useChessSounds"; diff --git a/src/lib/hooks/useChessSounds.ts b/src/lib/hooks/useChessSounds.ts new file mode 100644 index 0000000..202f0fc --- /dev/null +++ b/src/lib/hooks/useChessSounds.ts @@ -0,0 +1,109 @@ +"use client"; + +import { useRef, useEffect, useCallback } from "react"; + +export interface ChessSounds { + playMove: () => void; + playCapture: () => void; + playCheck: () => void; + playVictory: () => void; + playDefeat: () => void; + playMoveSound: (captured: boolean) => void; +} + +/** + * Custom hook for managing chess game sounds. + * Handles audio initialization and provides methods to play various game sounds. + */ +export function useChessSounds(): ChessSounds { + const moveSound = useRef(null); + const captureSound = useRef(null); + const checkSound = useRef(null); + const victorySound = useRef(null); + const defeatSound = useRef(null); + + // Initialize audio elements + useEffect(() => { + if (typeof window !== "undefined") { + moveSound.current = new Audio("/sounds/move.wav"); + captureSound.current = new Audio("/sounds/capture.wav"); + checkSound.current = new Audio("/sounds/check.wav"); + victorySound.current = new Audio("/sounds/victory.wav"); + defeatSound.current = new Audio("/sounds/defeat.wav"); + + // Preload audio files + [moveSound, captureSound, checkSound, victorySound, defeatSound].forEach( + (sound) => { + if (sound.current) { + sound.current.preload = "auto"; + } + } + ); + } + + // Cleanup + return () => { + [moveSound, captureSound, checkSound, victorySound, defeatSound].forEach( + (sound) => { + if (sound.current) { + sound.current.pause(); + sound.current = null; + } + } + ); + }; + }, []); + + const playSound = useCallback((sound: HTMLAudioElement | null) => { + if (sound) { + // Reset the sound to the beginning if it's still playing + sound.currentTime = 0; + sound.play().catch((e) => { + // Silently handle autoplay restrictions + if (e.name !== "NotAllowedError") { + console.error("Audio play failed", e); + } + }); + } + }, []); + + const playMove = useCallback(() => { + playSound(moveSound.current); + }, [playSound]); + + const playCapture = useCallback(() => { + playSound(captureSound.current); + }, [playSound]); + + const playCheck = useCallback(() => { + playSound(checkSound.current); + }, [playSound]); + + const playVictory = useCallback(() => { + playSound(victorySound.current); + }, [playSound]); + + const playDefeat = useCallback(() => { + playSound(defeatSound.current); + }, [playSound]); + + const playMoveSound = useCallback( + (captured: boolean) => { + if (captured) { + playCapture(); + } else { + playMove(); + } + }, + [playCapture, playMove] + ); + + return { + playMove, + playCapture, + playCheck, + playVictory, + playDefeat, + playMoveSound, + }; +} diff --git a/src/lib/i18n/translations.ts b/src/lib/i18n/translations.ts index dd4154a..8b1b759 100644 --- a/src/lib/i18n/translations.ts +++ b/src/lib/i18n/translations.ts @@ -61,6 +61,7 @@ export interface Translations { vs: string; backToMenu: string; stockfishStrength: string; + stockfishLevel: string; depth: string; undoMove: string; resign: string; @@ -71,6 +72,8 @@ export interface Translations { noMovesYet: string; white: string; black: string; + download: string; + evalChange: string; }; // Tutor @@ -269,6 +272,7 @@ const en: Translations = { vs: 'vs', backToMenu: '← Back to Menu', stockfishStrength: 'Stockfish Strength', + stockfishLevel: 'Stockfish Level', depth: 'Depth', undoMove: 'Undo Last Move', resign: 'Resign', @@ -279,6 +283,8 @@ const en: Translations = { noMovesYet: 'No moves yet.', white: 'White', black: 'Black', + download: 'Download', + evalChange: 'Eval Δ', }, tutor: { aiCoach: 'AI Coach', @@ -470,6 +476,7 @@ const de: Translations = { vs: 'gegen', backToMenu: '← Zurück zum Menü', stockfishStrength: 'Stockfish-Stärke', + stockfishLevel: 'Stockfish-Stufe', depth: 'Tiefe', undoMove: 'Letzten Zug rückgängig', resign: 'Aufgeben', @@ -480,6 +487,8 @@ const de: Translations = { noMovesYet: 'Noch keine Züge.', white: 'Weiß', black: 'Schwarz', + download: 'Herunterladen', + evalChange: 'Bew. Δ', }, tutor: { aiCoach: 'KI-Trainer', @@ -671,6 +680,7 @@ const fr: Translations = { vs: 'contre', backToMenu: '← Retour au menu', stockfishStrength: 'Force de Stockfish', + stockfishLevel: 'Niveau Stockfish', depth: 'Profondeur', undoMove: 'Annuler le dernier coup', resign: 'Abandonner', @@ -681,6 +691,8 @@ const fr: Translations = { noMovesYet: 'Aucun coup pour le moment.', white: 'Blancs', black: 'Noirs', + download: 'Télécharger', + evalChange: 'Éval Δ', }, tutor: { aiCoach: 'Coach IA', @@ -872,6 +884,7 @@ const it: Translations = { vs: 'contro', backToMenu: '← Torna al menu', stockfishStrength: 'Forza di Stockfish', + stockfishLevel: 'Livello Stockfish', depth: 'Profondità', undoMove: 'Annulla ultima mossa', resign: 'Abbandona', @@ -882,6 +895,8 @@ const it: Translations = { noMovesYet: 'Nessuna mossa ancora.', white: 'Bianco', black: 'Nero', + download: 'Scarica', + evalChange: 'Val Δ', }, tutor: { aiCoach: 'Allenatore IA', @@ -1073,6 +1088,7 @@ const pl: Translations = { vs: 'przeciw', backToMenu: '← Powrót do menu', stockfishStrength: 'Siła Stockfish', + stockfishLevel: 'Poziom Stockfish', depth: 'Głębokość', undoMove: 'Cofnij ruch', resign: 'Poddaj partię', @@ -1083,6 +1099,8 @@ const pl: Translations = { noMovesYet: 'Brak ruchów.', white: 'Białe', black: 'Czarne', + download: 'Pobierz', + evalChange: 'Ocena Δ', }, tutor: { aiCoach: 'Trener AI', diff --git a/src/lib/stockfish.ts b/src/lib/stockfish.ts index afaf4f0..c7936e6 100644 --- a/src/lib/stockfish.ts +++ b/src/lib/stockfish.ts @@ -6,6 +6,8 @@ export type StockfishEvaluation = { depth: number; }; +const EVALUATION_TIMEOUT_MS = 30000; // 30 seconds timeout for evaluation + export class Stockfish { private worker: Worker | null = null; private isReady: boolean = false; @@ -29,7 +31,7 @@ export class Stockfish { async evaluate(fen: string, depth: number = 15, multiPV: number = 1): Promise { return new Promise((resolve, reject) => { if (!this.worker) { - reject("Stockfish worker not initialized"); + reject(new Error("Stockfish worker not initialized")); return; } @@ -38,7 +40,20 @@ export class Stockfish { this.lastMate = null; this.lastDepth = 0; + let timeoutId: ReturnType | null = null; + let isResolved = false; + + const cleanup = () => { + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + this.worker?.removeEventListener("message", handler); + }; + const handler = (event: MessageEvent) => { + if (isResolved) return; + const message = event.data; // console.log("Stockfish:", message); @@ -66,8 +81,8 @@ export class Stockfish { ponder = parts[3]; } - // Remove the event listener to prevent it from interfering with future evaluations - this.worker?.removeEventListener("message", handler); + isResolved = true; + cleanup(); resolve({ bestMove, ponder, @@ -78,6 +93,17 @@ export class Stockfish { } }; + // Set timeout to prevent hanging promises + timeoutId = setTimeout(() => { + if (!isResolved) { + isResolved = true; + cleanup(); + // Stop any ongoing analysis + this.worker?.postMessage("stop"); + reject(new Error(`Stockfish evaluation timed out after ${EVALUATION_TIMEOUT_MS / 1000} seconds`)); + } + }, EVALUATION_TIMEOUT_MS); + this.worker.addEventListener("message", handler); this.worker.postMessage(`position fen ${fen}`); this.worker.postMessage(`go depth ${depth}`); diff --git a/src/lib/tacticDetection.ts b/src/lib/tacticDetection.ts index d0dafaf..74c2ebc 100644 --- a/src/lib/tacticDetection.ts +++ b/src/lib/tacticDetection.ts @@ -361,9 +361,23 @@ export function detectMissedTactics({ detectionResults.push(...detectFork(chessAfter, playerColor, move.san)); detectionResults.push(...detectHangingPieces(chessAfter, playerColor, move.san)); - if (detectionResults.length === 0) { - return [{ tactic_type: "none", move: move.san }]; - } - + // Return empty array if no tactics detected (cleaner than returning "none" type) return detectionResults; } + +/** + * Helper function to check if tactics were detected. + * Use this instead of checking array length to ensure type safety. + */ +export function hasTactics(tactics: DetectedTactic[] | null | undefined): boolean { + return tactics != null && tactics.length > 0; +} + +/** + * Filter out "none" type tactics for backward compatibility with old data. + * New code should use empty arrays, but this handles legacy data. + */ +export function filterMeaningfulTactics(tactics: DetectedTactic[] | null | undefined): DetectedTactic[] { + if (!tactics) return []; + return tactics.filter(t => t.tactic_type !== "none"); +}