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
This commit is contained in:
+1
-1
@@ -20,7 +20,7 @@ const config: Config = {
|
|||||||
'/e2e/', // Exclude Playwright e2e tests
|
'/e2e/', // Exclude Playwright e2e tests
|
||||||
],
|
],
|
||||||
transformIgnorePatterns: [
|
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)/)',
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,10 +31,15 @@ jest.mock("@/lib/stockfish", () => {
|
|||||||
jest.mock("@/lib/tacticDetection", () => {
|
jest.mock("@/lib/tacticDetection", () => {
|
||||||
const detectMissedTactics = jest.fn();
|
const detectMissedTactics = jest.fn();
|
||||||
const uciToSan = 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 {
|
return {
|
||||||
__esModule: true,
|
__esModule: true,
|
||||||
detectMissedTactics,
|
detectMissedTactics,
|
||||||
uciToSan,
|
uciToSan,
|
||||||
|
filterMeaningfulTactics,
|
||||||
__mock: { detectMissedTactics, uciToSan },
|
__mock: { detectMissedTactics, uciToSan },
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { useTranslation } from "@/lib/i18n/useTranslation";
|
|||||||
import { Personality, PERSONALITIES } from "@/lib/personalities";
|
import { Personality, PERSONALITIES } from "@/lib/personalities";
|
||||||
import { Stockfish, StockfishEvaluation } from "@/lib/stockfish";
|
import { Stockfish, StockfishEvaluation } from "@/lib/stockfish";
|
||||||
import { detectChessFormat, ChessFormat } from "@/lib/chessFormatDetector";
|
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 { lookupPossibleOpenings, buildMoveSequenceFromSteps, OpeningMetadata } from "@/lib/openings";
|
||||||
import { getGenAIModel } from "@/lib/gemini";
|
import { getGenAIModel } from "@/lib/gemini";
|
||||||
import { ChatSession } from "@google/generative-ai";
|
import { ChatSession } from "@google/generative-ai";
|
||||||
@@ -326,8 +326,7 @@ IMPORTANT:
|
|||||||
const evalBefore = details.evalBefore!.score / 100;
|
const evalBefore = details.evalBefore!.score / 100;
|
||||||
const evalAfter = details.evalAfter!.score / 100;
|
const evalAfter = details.evalAfter!.score / 100;
|
||||||
const mateInfo = details.evalAfter!.mate !== null ? `Mate in ${details.evalAfter!.mate}` : "No mate detected";
|
const mateInfo = details.evalAfter!.mate !== null ? `Mate in ${details.evalAfter!.mate}` : "No mate detected";
|
||||||
const tactics = (details.missedTactics || [])
|
const tactics = filterMeaningfulTactics(details.missedTactics)
|
||||||
.filter(t => t.tactic_type !== "none")
|
|
||||||
.map(t => `${t.tactic_type}${t.material_delta ? ` (~${(t.material_delta / 100).toFixed(1)} pawns)` : ""}`)
|
.map(t => `${t.tactic_type}${t.material_delta ? ` (~${(t.material_delta / 100).toFixed(1)} pawns)` : ""}`)
|
||||||
.join("; ") || "None";
|
.join("; ") || "None";
|
||||||
|
|
||||||
@@ -406,7 +405,7 @@ INSTRUCTIONS:
|
|||||||
};
|
};
|
||||||
|
|
||||||
const currentDetails = currentIndex > 0 ? stepDetails[currentIndex] : undefined;
|
const currentDetails = currentIndex > 0 ? stepDetails[currentIndex] : undefined;
|
||||||
const tacticSummary = (currentDetails?.missedTactics || []).filter(t => t.tactic_type !== "none");
|
const tacticSummary = filterMeaningfulTactics(currentDetails?.missedTactics);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-h-screen bg-gray-100 dark:bg-gray-900">
|
<div className="flex flex-col min-h-screen bg-gray-100 dark:bg-gray-900">
|
||||||
|
|||||||
@@ -1,9 +1,22 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { evaluateStockfish } from "@/lib/server/stockfishEngine";
|
import { evaluateStockfish } from "@/lib/server/stockfishEngine";
|
||||||
import { StockfishEvaluation } from "@/lib/stockfish";
|
import { StockfishEvaluation } from "@/lib/stockfish";
|
||||||
|
import { Chess } from "chess.js";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
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) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
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 });
|
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 parsedDepth = Number(depth);
|
||||||
const parsedMultiPV = Number(multiPV);
|
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 });
|
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) {
|
if (!Number.isFinite(parsedMultiPV) || parsedMultiPV <= 0) {
|
||||||
return NextResponse.json({ error: "multiPV must be a positive number" }, { status: 400 });
|
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 });
|
return NextResponse.json({ evaluation });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Stockfish API error", error);
|
console.error("Stockfish API error", error);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { memo, useMemo } from 'react';
|
||||||
|
|
||||||
interface CapturedPiecesProps {
|
interface CapturedPiecesProps {
|
||||||
captured: string[]; // Array of piece types, e.g., ['p', 'n', 'q']
|
captured: string[]; // Array of piece types, e.g., ['p', 'n', 'q']
|
||||||
@@ -15,10 +15,18 @@ const PIECE_ICONS: Record<string, string> = {
|
|||||||
'k': '♚', // King is never captured, but for completeness
|
'k': '♚', // King is never captured, but for completeness
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CapturedPieces: React.FC<CapturedPiecesProps> = ({ captured, color, score }) => {
|
|
||||||
// Sort pieces by value for better display: Q, R, B, N, P
|
|
||||||
const sortOrder = ['q', 'r', 'b', 'n', 'p'];
|
const sortOrder = ['q', 'r', 'b', 'n', 'p'];
|
||||||
const sortedPieces = [...captured].sort((a, b) => sortOrder.indexOf(a) - sortOrder.indexOf(b));
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
return (
|
||||||
<div className="flex items-center h-8 gap-2 text-gray-600 dark:text-gray-300">
|
<div className="flex items-center h-8 gap-2 text-gray-600 dark:text-gray-300">
|
||||||
@@ -36,4 +44,4 @@ export const CapturedPieces: React.FC<CapturedPiecesProps> = ({ captured, color,
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { Brain, ArrowLeft, Download, Flag, AlertTriangle, X } from "lucide-react
|
|||||||
import { CapturedPieces } from "./CapturedPieces";
|
import { CapturedPieces } from "./CapturedPieces";
|
||||||
import { detectMissedTactics, uciToSan, DetectedTactic } from "@/lib/tacticDetection";
|
import { detectMissedTactics, uciToSan, DetectedTactic } from "@/lib/tacticDetection";
|
||||||
import { upsertSavedGame } from "@/lib/savedGames";
|
import { upsertSavedGame } from "@/lib/savedGames";
|
||||||
|
import { useChessSounds } from "@/lib/hooks/useChessSounds";
|
||||||
|
|
||||||
interface ChessGameProps {
|
interface ChessGameProps {
|
||||||
gameId: string;
|
gameId: string;
|
||||||
@@ -87,28 +88,8 @@ export default function ChessGame({ gameId, initialFen, initialPgn, initialPerso
|
|||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const hasRebuiltHistoryRef = useRef(false);
|
const hasRebuiltHistoryRef = useRef(false);
|
||||||
|
|
||||||
// Sound Refs
|
// Chess sounds hook
|
||||||
const moveSound = useRef<HTMLAudioElement | null>(null);
|
const { playMoveSound, playCheck, playVictory, playDefeat } = useChessSounds();
|
||||||
const captureSound = useRef<HTMLAudioElement | null>(null);
|
|
||||||
const checkSound = useRef<HTMLAudioElement | null>(null);
|
|
||||||
const victorySound = useRef<HTMLAudioElement | null>(null);
|
|
||||||
const defeatSound = useRef<HTMLAudioElement | null>(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));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Removed auto-scroll to prevent page jumping when moves are added
|
// Removed auto-scroll to prevent page jumping when moves are added
|
||||||
// Users can manually scroll to see move history if needed
|
// 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') {
|
if (game.turn() === 'w') {
|
||||||
result = "Checkmate! You lost.";
|
result = "Checkmate! You lost.";
|
||||||
winner = "Black";
|
winner = "Black";
|
||||||
if (playerColor === 'white') defeatSound.current?.play().catch(e => console.error(e));
|
if (playerColor === 'white') playDefeat();
|
||||||
else victorySound.current?.play().catch(e => console.error(e));
|
else playVictory();
|
||||||
} else {
|
} else {
|
||||||
result = "Checkmate! You won!";
|
result = "Checkmate! You won!";
|
||||||
winner = "White";
|
winner = "White";
|
||||||
if (playerColor === 'white') victorySound.current?.play().catch(e => console.error(e));
|
if (playerColor === 'white') playVictory();
|
||||||
else defeatSound.current?.play().catch(e => console.error(e));
|
else playDefeat();
|
||||||
}
|
}
|
||||||
} else if (game.isDraw()) {
|
} else if (game.isDraw()) {
|
||||||
result = "Draw!";
|
result = "Draw!";
|
||||||
@@ -349,12 +330,12 @@ export default function ChessGame({ gameId, initialFen, initialPgn, initialPerso
|
|||||||
result = "Stalemate!";
|
result = "Stalemate!";
|
||||||
winner = "Draw";
|
winner = "Draw";
|
||||||
} else if (game.inCheck()) {
|
} else if (game.inCheck()) {
|
||||||
checkSound.current?.play().catch(e => console.error(e));
|
playCheck();
|
||||||
}
|
}
|
||||||
|
|
||||||
setGameOverState({ result, winner });
|
setGameOverState({ result, winner });
|
||||||
}
|
}
|
||||||
}, [fen, playerColor]);
|
}, [fen, playerColor, playDefeat, playVictory, playCheck]);
|
||||||
|
|
||||||
// Pre-Analysis (P0)
|
// Pre-Analysis (P0)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -764,12 +745,12 @@ export default function ChessGame({ gameId, initialFen, initialPgn, initialPerso
|
|||||||
onClick={() => setShowStrengthSlider(!showStrengthSlider)}
|
onClick={() => setShowStrengthSlider(!showStrengthSlider)}
|
||||||
className="hover:text-gray-700 dark:hover:text-gray-200 underline decoration-dotted underline-offset-2"
|
className="hover:text-gray-700 dark:hover:text-gray-200 underline decoration-dotted underline-offset-2"
|
||||||
>
|
>
|
||||||
Stockfish Level: {stockfishDepth}
|
{t.game.stockfishLevel}: {stockfishDepth}
|
||||||
</button>
|
</button>
|
||||||
{showStrengthSlider && (
|
{showStrengthSlider && (
|
||||||
<div className="absolute bottom-full left-0 mb-2 w-48 bg-white dark:bg-gray-700 p-3 rounded shadow-xl border border-gray-200 dark:border-gray-600 z-10">
|
<div className="absolute bottom-full left-0 mb-2 w-48 bg-white dark:bg-gray-700 p-3 rounded shadow-xl border border-gray-200 dark:border-gray-600 z-10">
|
||||||
<label className="block text-xs font-bold mb-1 text-gray-700 dark:text-gray-200">
|
<label className="block text-xs font-bold mb-1 text-gray-700 dark:text-gray-200">
|
||||||
Strength (Depth: {stockfishDepth})
|
{t.game.stockfishStrength} ({t.game.depth}: {stockfishDepth})
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
@@ -862,7 +843,7 @@ export default function ChessGame({ gameId, initialFen, initialPgn, initialPerso
|
|||||||
onClick={() => setShowDownloadModal(true)}
|
onClick={() => 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"
|
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 size={12} /> Download
|
<Download size={12} /> {t.game.download}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAnalysisModal(true)}
|
onClick={() => setShowAnalysisModal(true)}
|
||||||
@@ -879,7 +860,7 @@ export default function ChessGame({ gameId, initialFen, initialPgn, initialPerso
|
|||||||
<th className="py-1 px-2 w-12">#</th>
|
<th className="py-1 px-2 w-12">#</th>
|
||||||
<th className="py-1 px-2">{t.game.white}</th>
|
<th className="py-1 px-2">{t.game.white}</th>
|
||||||
<th className="py-1 px-2">{t.game.black}</th>
|
<th className="py-1 px-2">{t.game.black}</th>
|
||||||
<th className="py-1 px-2 text-center w-20">Eval Δ</th>
|
<th className="py-1 px-2 text-center w-20">{t.game.evalChange}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { Component, ReactNode } from "react";
|
||||||
|
import { AlertTriangle, RefreshCw } from "lucide-react";
|
||||||
|
|
||||||
|
interface ErrorBoundaryProps {
|
||||||
|
children: ReactNode;
|
||||||
|
fallback?: ReactNode;
|
||||||
|
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorBoundaryState {
|
||||||
|
hasError: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error Boundary component to catch and handle React errors gracefully.
|
||||||
|
* Prevents the entire app from crashing when a component throws an error.
|
||||||
|
*/
|
||||||
|
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||||
|
constructor(props: ErrorBoundaryProps) {
|
||||||
|
super(props);
|
||||||
|
this.state = { hasError: false, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||||
|
console.error("ErrorBoundary caught an error:", error, errorInfo);
|
||||||
|
this.props.onError?.(error, errorInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleReset = () => {
|
||||||
|
this.setState({ hasError: false, error: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
if (this.props.fallback) {
|
||||||
|
return this.props.fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center p-8 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-800">
|
||||||
|
<AlertTriangle className="w-12 h-12 text-red-500 mb-4" />
|
||||||
|
<h2 className="text-lg font-semibold text-red-700 dark:text-red-300 mb-2">
|
||||||
|
Something went wrong
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400 mb-4 text-center max-w-md">
|
||||||
|
{this.state.error?.message || "An unexpected error occurred"}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={this.handleReset}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} />
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Specialized Error Boundary for the Chess Game component
|
||||||
|
*/
|
||||||
|
export function ChessGameErrorFallback({ onRetry }: { onRetry?: () => void }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center p-8 bg-gray-100 dark:bg-gray-800 rounded-lg min-h-[400px]">
|
||||||
|
<AlertTriangle className="w-16 h-16 text-amber-500 mb-4" />
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-2">
|
||||||
|
Chess Engine Error
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-6 text-center max-w-md">
|
||||||
|
There was a problem loading the chess engine. This might be due to a browser
|
||||||
|
compatibility issue or network problem.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} />
|
||||||
|
Reload Page
|
||||||
|
</button>
|
||||||
|
{onRetry && (
|
||||||
|
<button
|
||||||
|
onClick={onRetry}
|
||||||
|
className="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
|
||||||
|
>
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Specialized Error Boundary for the Tutor/Chat component
|
||||||
|
*/
|
||||||
|
export function TutorErrorFallback({ onRetry }: { onRetry?: () => void }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center p-6 bg-gray-100 dark:bg-gray-800 rounded-lg h-full min-h-[300px]">
|
||||||
|
<AlertTriangle className="w-10 h-10 text-amber-500 mb-3" />
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||||
|
Tutor Unavailable
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4 text-center">
|
||||||
|
The AI tutor encountered an error. You can continue playing without assistance.
|
||||||
|
</p>
|
||||||
|
{onRetry && (
|
||||||
|
<button
|
||||||
|
onClick={onRetry}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} />
|
||||||
|
Reconnect
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ErrorBoundary;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { memo } from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
interface EvaluationBarProps {
|
interface EvaluationBarProps {
|
||||||
@@ -9,7 +10,11 @@ interface EvaluationBarProps {
|
|||||||
orientation?: 'vertical' | 'horizontal';
|
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
|
// Calculate white's percentage height/width
|
||||||
// Using sigmoid-like function for score: P = 1 / (1 + 10^(-score/400))
|
// Using sigmoid-like function for score: P = 1 / (1 + 10^(-score/400))
|
||||||
// This is a standard way to visualize CP advantage.
|
// This is a standard way to visualize CP advantage.
|
||||||
@@ -78,4 +83,4 @@ export function EvaluationBar({ score, mate, isPlayerWhite, orientation = 'verti
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState, useEffect, useRef } from "react";
|
|||||||
import { getGenAIModel } from "@/lib/gemini";
|
import { getGenAIModel } from "@/lib/gemini";
|
||||||
import { Loader2, X, Trophy, AlertTriangle, RefreshCw } from "lucide-react";
|
import { Loader2, X, Trophy, AlertTriangle, RefreshCw } from "lucide-react";
|
||||||
import { StockfishEvaluation } from "@/lib/stockfish";
|
import { StockfishEvaluation } from "@/lib/stockfish";
|
||||||
import { DetectedTactic } from "@/lib/tacticDetection";
|
import { DetectedTactic, filterMeaningfulTactics } from "@/lib/tacticDetection";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import { SupportedLanguage } from "@/lib/i18n/translations";
|
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 inaccuracies = detectedMistakes.filter(m => m.category === 'inaccuracy');
|
||||||
|
|
||||||
const describeTactics = (tactics?: DetectedTactic[]) => {
|
const describeTactics = (tactics?: DetectedTactic[]) => {
|
||||||
if (!tactics || tactics.length === 0) return "";
|
const meaningful = filterMeaningfulTactics(tactics);
|
||||||
const meaningful = tactics.filter(t => t.tactic_type !== 'none');
|
|
||||||
if (meaningful.length === 0) return "";
|
if (meaningful.length === 0) return "";
|
||||||
return meaningful.map(t => {
|
return meaningful.map(t => {
|
||||||
const material = t.material_delta ? ` (~${t.material_delta}cp)` : '';
|
const material = t.material_delta ? ` (~${t.material_delta}cp)` : '';
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import ReactMarkdown from "react-markdown";
|
|||||||
|
|
||||||
import { useTranslation } from '@/lib/i18n/useTranslation';
|
import { useTranslation } from '@/lib/i18n/useTranslation';
|
||||||
import { SupportedLanguage } from '@/lib/i18n/translations';
|
import { SupportedLanguage } from '@/lib/i18n/translations';
|
||||||
import { DetectedTactic } from '@/lib/tacticDetection';
|
import { DetectedTactic, filterMeaningfulTactics } from '@/lib/tacticDetection';
|
||||||
import { useDebug } from '@/contexts/DebugContext';
|
import { useDebug } from '@/contexts/DebugContext';
|
||||||
import { MoveHistoryItem } from './GameOverModal';
|
import { MoveHistoryItem } from './GameOverModal';
|
||||||
import { parseGeminiError, GeminiErrorInfo, isGeminiError } from '@/lib/geminiErrorHandler';
|
import { parseGeminiError, GeminiErrorInfo, isGeminiError } from '@/lib/geminiErrorHandler';
|
||||||
@@ -582,7 +582,7 @@ INSTRUCTIONS:
|
|||||||
// Tactical Analysis Instruction
|
// Tactical Analysis Instruction
|
||||||
let tacticalInstruction = "";
|
let tacticalInstruction = "";
|
||||||
if (missedTactics && missedTactics.length > 0) {
|
if (missedTactics && missedTactics.length > 0) {
|
||||||
const meaningfulTactics = missedTactics.filter(t => t.tactic_type !== 'none');
|
const meaningfulTactics = filterMeaningfulTactics(missedTactics);
|
||||||
if (meaningfulTactics.length > 0) {
|
if (meaningfulTactics.length > 0) {
|
||||||
const tacticDescriptions = meaningfulTactics.map(t => {
|
const tacticDescriptions = meaningfulTactics.map(t => {
|
||||||
let desc = `- ${t.tactic_type.toUpperCase()}`;
|
let desc = `- ${t.tactic_type.toUpperCase()}`;
|
||||||
|
|||||||
@@ -10,6 +10,11 @@
|
|||||||
* This is Phase 5 of the refactoring plan - comprehensive testing.
|
* 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 React from 'react';
|
||||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||||
import { OpeningTrainingProvider, useOpeningTraining } from '../OpeningTrainingContext';
|
import { OpeningTrainingProvider, useOpeningTraining } from '../OpeningTrainingContext';
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ describe('tacticDetection', () => {
|
|||||||
expect(result).toEqual([]);
|
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 fen = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1';
|
||||||
const result = detectMissedTactics({
|
const result = detectMissedTactics({
|
||||||
fen,
|
fen,
|
||||||
@@ -180,8 +180,8 @@ describe('tacticDetection', () => {
|
|||||||
cpLoss: 60,
|
cpLoss: 60,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.length).toBe(1);
|
// Returns empty array when no specific tactics detected
|
||||||
expect(result[0].tactic_type).toBe('none');
|
expect(result).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle custom evalLossThreshold', () => {
|
it('should handle custom evalLossThreshold', () => {
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { useChessSounds } from "./useChessSounds";
|
||||||
|
export type { ChessSounds } from "./useChessSounds";
|
||||||
@@ -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<HTMLAudioElement | null>(null);
|
||||||
|
const captureSound = useRef<HTMLAudioElement | null>(null);
|
||||||
|
const checkSound = useRef<HTMLAudioElement | null>(null);
|
||||||
|
const victorySound = useRef<HTMLAudioElement | null>(null);
|
||||||
|
const defeatSound = useRef<HTMLAudioElement | null>(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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -61,6 +61,7 @@ export interface Translations {
|
|||||||
vs: string;
|
vs: string;
|
||||||
backToMenu: string;
|
backToMenu: string;
|
||||||
stockfishStrength: string;
|
stockfishStrength: string;
|
||||||
|
stockfishLevel: string;
|
||||||
depth: string;
|
depth: string;
|
||||||
undoMove: string;
|
undoMove: string;
|
||||||
resign: string;
|
resign: string;
|
||||||
@@ -71,6 +72,8 @@ export interface Translations {
|
|||||||
noMovesYet: string;
|
noMovesYet: string;
|
||||||
white: string;
|
white: string;
|
||||||
black: string;
|
black: string;
|
||||||
|
download: string;
|
||||||
|
evalChange: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Tutor
|
// Tutor
|
||||||
@@ -269,6 +272,7 @@ const en: Translations = {
|
|||||||
vs: 'vs',
|
vs: 'vs',
|
||||||
backToMenu: '← Back to Menu',
|
backToMenu: '← Back to Menu',
|
||||||
stockfishStrength: 'Stockfish Strength',
|
stockfishStrength: 'Stockfish Strength',
|
||||||
|
stockfishLevel: 'Stockfish Level',
|
||||||
depth: 'Depth',
|
depth: 'Depth',
|
||||||
undoMove: 'Undo Last Move',
|
undoMove: 'Undo Last Move',
|
||||||
resign: 'Resign',
|
resign: 'Resign',
|
||||||
@@ -279,6 +283,8 @@ const en: Translations = {
|
|||||||
noMovesYet: 'No moves yet.',
|
noMovesYet: 'No moves yet.',
|
||||||
white: 'White',
|
white: 'White',
|
||||||
black: 'Black',
|
black: 'Black',
|
||||||
|
download: 'Download',
|
||||||
|
evalChange: 'Eval Δ',
|
||||||
},
|
},
|
||||||
tutor: {
|
tutor: {
|
||||||
aiCoach: 'AI Coach',
|
aiCoach: 'AI Coach',
|
||||||
@@ -470,6 +476,7 @@ const de: Translations = {
|
|||||||
vs: 'gegen',
|
vs: 'gegen',
|
||||||
backToMenu: '← Zurück zum Menü',
|
backToMenu: '← Zurück zum Menü',
|
||||||
stockfishStrength: 'Stockfish-Stärke',
|
stockfishStrength: 'Stockfish-Stärke',
|
||||||
|
stockfishLevel: 'Stockfish-Stufe',
|
||||||
depth: 'Tiefe',
|
depth: 'Tiefe',
|
||||||
undoMove: 'Letzten Zug rückgängig',
|
undoMove: 'Letzten Zug rückgängig',
|
||||||
resign: 'Aufgeben',
|
resign: 'Aufgeben',
|
||||||
@@ -480,6 +487,8 @@ const de: Translations = {
|
|||||||
noMovesYet: 'Noch keine Züge.',
|
noMovesYet: 'Noch keine Züge.',
|
||||||
white: 'Weiß',
|
white: 'Weiß',
|
||||||
black: 'Schwarz',
|
black: 'Schwarz',
|
||||||
|
download: 'Herunterladen',
|
||||||
|
evalChange: 'Bew. Δ',
|
||||||
},
|
},
|
||||||
tutor: {
|
tutor: {
|
||||||
aiCoach: 'KI-Trainer',
|
aiCoach: 'KI-Trainer',
|
||||||
@@ -671,6 +680,7 @@ const fr: Translations = {
|
|||||||
vs: 'contre',
|
vs: 'contre',
|
||||||
backToMenu: '← Retour au menu',
|
backToMenu: '← Retour au menu',
|
||||||
stockfishStrength: 'Force de Stockfish',
|
stockfishStrength: 'Force de Stockfish',
|
||||||
|
stockfishLevel: 'Niveau Stockfish',
|
||||||
depth: 'Profondeur',
|
depth: 'Profondeur',
|
||||||
undoMove: 'Annuler le dernier coup',
|
undoMove: 'Annuler le dernier coup',
|
||||||
resign: 'Abandonner',
|
resign: 'Abandonner',
|
||||||
@@ -681,6 +691,8 @@ const fr: Translations = {
|
|||||||
noMovesYet: 'Aucun coup pour le moment.',
|
noMovesYet: 'Aucun coup pour le moment.',
|
||||||
white: 'Blancs',
|
white: 'Blancs',
|
||||||
black: 'Noirs',
|
black: 'Noirs',
|
||||||
|
download: 'Télécharger',
|
||||||
|
evalChange: 'Éval Δ',
|
||||||
},
|
},
|
||||||
tutor: {
|
tutor: {
|
||||||
aiCoach: 'Coach IA',
|
aiCoach: 'Coach IA',
|
||||||
@@ -872,6 +884,7 @@ const it: Translations = {
|
|||||||
vs: 'contro',
|
vs: 'contro',
|
||||||
backToMenu: '← Torna al menu',
|
backToMenu: '← Torna al menu',
|
||||||
stockfishStrength: 'Forza di Stockfish',
|
stockfishStrength: 'Forza di Stockfish',
|
||||||
|
stockfishLevel: 'Livello Stockfish',
|
||||||
depth: 'Profondità',
|
depth: 'Profondità',
|
||||||
undoMove: 'Annulla ultima mossa',
|
undoMove: 'Annulla ultima mossa',
|
||||||
resign: 'Abbandona',
|
resign: 'Abbandona',
|
||||||
@@ -882,6 +895,8 @@ const it: Translations = {
|
|||||||
noMovesYet: 'Nessuna mossa ancora.',
|
noMovesYet: 'Nessuna mossa ancora.',
|
||||||
white: 'Bianco',
|
white: 'Bianco',
|
||||||
black: 'Nero',
|
black: 'Nero',
|
||||||
|
download: 'Scarica',
|
||||||
|
evalChange: 'Val Δ',
|
||||||
},
|
},
|
||||||
tutor: {
|
tutor: {
|
||||||
aiCoach: 'Allenatore IA',
|
aiCoach: 'Allenatore IA',
|
||||||
@@ -1073,6 +1088,7 @@ const pl: Translations = {
|
|||||||
vs: 'przeciw',
|
vs: 'przeciw',
|
||||||
backToMenu: '← Powrót do menu',
|
backToMenu: '← Powrót do menu',
|
||||||
stockfishStrength: 'Siła Stockfish',
|
stockfishStrength: 'Siła Stockfish',
|
||||||
|
stockfishLevel: 'Poziom Stockfish',
|
||||||
depth: 'Głębokość',
|
depth: 'Głębokość',
|
||||||
undoMove: 'Cofnij ruch',
|
undoMove: 'Cofnij ruch',
|
||||||
resign: 'Poddaj partię',
|
resign: 'Poddaj partię',
|
||||||
@@ -1083,6 +1099,8 @@ const pl: Translations = {
|
|||||||
noMovesYet: 'Brak ruchów.',
|
noMovesYet: 'Brak ruchów.',
|
||||||
white: 'Białe',
|
white: 'Białe',
|
||||||
black: 'Czarne',
|
black: 'Czarne',
|
||||||
|
download: 'Pobierz',
|
||||||
|
evalChange: 'Ocena Δ',
|
||||||
},
|
},
|
||||||
tutor: {
|
tutor: {
|
||||||
aiCoach: 'Trener AI',
|
aiCoach: 'Trener AI',
|
||||||
|
|||||||
+29
-3
@@ -6,6 +6,8 @@ export type StockfishEvaluation = {
|
|||||||
depth: number;
|
depth: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const EVALUATION_TIMEOUT_MS = 30000; // 30 seconds timeout for evaluation
|
||||||
|
|
||||||
export class Stockfish {
|
export class Stockfish {
|
||||||
private worker: Worker | null = null;
|
private worker: Worker | null = null;
|
||||||
private isReady: boolean = false;
|
private isReady: boolean = false;
|
||||||
@@ -29,7 +31,7 @@ export class Stockfish {
|
|||||||
async evaluate(fen: string, depth: number = 15, multiPV: number = 1): Promise<StockfishEvaluation> {
|
async evaluate(fen: string, depth: number = 15, multiPV: number = 1): Promise<StockfishEvaluation> {
|
||||||
return new Promise<StockfishEvaluation>((resolve, reject) => {
|
return new Promise<StockfishEvaluation>((resolve, reject) => {
|
||||||
if (!this.worker) {
|
if (!this.worker) {
|
||||||
reject("Stockfish worker not initialized");
|
reject(new Error("Stockfish worker not initialized"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,7 +40,20 @@ export class Stockfish {
|
|||||||
this.lastMate = null;
|
this.lastMate = null;
|
||||||
this.lastDepth = 0;
|
this.lastDepth = 0;
|
||||||
|
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let isResolved = false;
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
timeoutId = null;
|
||||||
|
}
|
||||||
|
this.worker?.removeEventListener("message", handler);
|
||||||
|
};
|
||||||
|
|
||||||
const handler = (event: MessageEvent) => {
|
const handler = (event: MessageEvent) => {
|
||||||
|
if (isResolved) return;
|
||||||
|
|
||||||
const message = event.data;
|
const message = event.data;
|
||||||
// console.log("Stockfish:", message);
|
// console.log("Stockfish:", message);
|
||||||
|
|
||||||
@@ -66,8 +81,8 @@ export class Stockfish {
|
|||||||
ponder = parts[3];
|
ponder = parts[3];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the event listener to prevent it from interfering with future evaluations
|
isResolved = true;
|
||||||
this.worker?.removeEventListener("message", handler);
|
cleanup();
|
||||||
resolve({
|
resolve({
|
||||||
bestMove,
|
bestMove,
|
||||||
ponder,
|
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.addEventListener("message", handler);
|
||||||
this.worker.postMessage(`position fen ${fen}`);
|
this.worker.postMessage(`position fen ${fen}`);
|
||||||
this.worker.postMessage(`go depth ${depth}`);
|
this.worker.postMessage(`go depth ${depth}`);
|
||||||
|
|||||||
@@ -361,9 +361,23 @@ export function detectMissedTactics({
|
|||||||
detectionResults.push(...detectFork(chessAfter, playerColor, move.san));
|
detectionResults.push(...detectFork(chessAfter, playerColor, move.san));
|
||||||
detectionResults.push(...detectHangingPieces(chessAfter, playerColor, move.san));
|
detectionResults.push(...detectHangingPieces(chessAfter, playerColor, move.san));
|
||||||
|
|
||||||
if (detectionResults.length === 0) {
|
// Return empty array if no tactics detected (cleaner than returning "none" type)
|
||||||
return [{ tactic_type: "none", move: move.san }];
|
|
||||||
}
|
|
||||||
|
|
||||||
return detectionResults;
|
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");
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user