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}