diff --git a/docs/tactic-detection-requirements.md b/docs/tactic-detection-requirements.md new file mode 100644 index 0000000..2e30dd5 --- /dev/null +++ b/docs/tactic-detection-requirements.md @@ -0,0 +1,74 @@ +# Taktik-Erkennungsmodul – Requirements + +## Ziel und Rolle im System +- Ergänzt die vorhandene Engine- und LLM-Pipeline: wird nach der Engine-Analyse eingeschoben, bevor das LLM Feedback generiert. +- Aufgabe: Wenn der Spieler **nicht** den Engine-Bestzug gespielt hat, identifiziert das Modul, ob der Bestzug ein klassisches taktisches Motiv auslöst (z. B. Figurgewinn, Pin, Fork) und stellt diese Information strukturiert für das LLM bereit. +- Nichts bestehendes wird ersetzt; es liefert nur zusätzliche, konservative Taktik-Hinweise. + +## Eingaben (pro Spielerzug) +- Ausgangsstellung als **FEN**. +- **Spielerfarbe**. +- **Gespielter Zug** des Spielers. +- **Engine-Bestzug** für die Ausgangsstellung. +- Optional: 1–2 Halbzüge der **PV** des Bestzugs (z. B. Bestzug + Antwort). +- Optional: **Eval-Infos** vor/nach dem Zug bzw. nach Bestzug, falls zur Filterung genutzt. +- Das Modul ruft selbst **keine Engine** auf; es nutzt nur bereitgestellte Daten. + +## Ausgabe +- Liste erkannter Motive (leer, wenn nichts gefunden), jedes mit: + - `tactic_type` – z. B. `win_material`, `win_pawn`, `pin`, `fork`, `skewer`, `check`, `hanging_piece`, `improve_activity`, `none`. + - `affected_squares` – optionale Felderliste. + - `piece_roles` – beteiligte Figuren, inkl. Farbe (z. B. „weißer Läufer“, „schwarzer Springer“). + - `material_delta` – grobe Materialeinschätzung (Centipawns, z. B. +100 Bauer, +300 Leichtfigur). + - `move` – der Engine-Bestzug, auf den sich das Motiv bezieht. +- Diese Struktur wird an das LLM weitergereicht, damit es textuelle Hinweise erzeugt (z. B. „Mit Lxd5 hättest du einen Bauern gewinnen können“). + +## Wann das Modul aktiv wird +- Nur prüfen, wenn der Engine-Bestzug **vom gespielten Zug abweicht**. +- Optionaler Filter: Eval-Verlust über Schwellwert (z. B. >50–100 Centipawns) als Signal für Relevanz. +- Ziel: Rechenaufwand sparen und Rauschen vermeiden. + +## Ablauf pro relevanter Stellung +1. **Stellung herstellen**: FEN laden, Spielerfarbe setzen. +2. **Bestzug anwenden**: Hypothetisch Engine-Bestzug ausführen. +3. **Resultierende Stellung prüfen**: Regelbasierte Checks (ohne neue Engine-Aufrufe) für taktische Muster. +4. **Motive sammeln**: Alle erkannten Motive in strukturierter Form zusammenstellen. +5. **An LLM übergeben**: Zusammen mit gespielt vs. Bestzug und ggf. Eval-Verlust. + +## Taktik-Checks (konservativ) +- **Materialgewinn / Capture** + - Prüfen, ob Bestzug eine gegnerische Figur/Bauern schlägt. + - Figurwert bestimmen; Plausibilität, dass die schlagende Figur nicht sofort mit klarem Materialverlust verloren geht. + - Ergebnis: `tactic_type` = `win_piece` oder `win_pawn`. +- **Pin / Fesselung** + - Nach Bestzug: greift ein Läufer/Turm/Dame eine gegnerische Figur an, hinter der auf derselben Linie König oder wertvolle Figur steht? + - Ergebnis: `tactic_type` = `pin`. +- **Fork / Gabel** + - Greift die ziehende Figur gleichzeitig ≥2 wertvolle gegnerische Ziele (z. B. König+Dame, Dame+Turm, zwei Leichtfiguren)? + - Ergebnis: `tactic_type` = `fork`. +- **Skewer** + - Linienangriff, bei dem eine höherwertige Figur vor einer weniger wertvollen steht und nach Abzug Material fällt. + - Ergebnis: `tactic_type` = `skewer`. +- **Schach / direkte Drohung** + - Bestzug gibt Schach; ggf. kombinieren mit PV-Infos, wenn das Schach Materialgewinn erzwingt. + - Ergebnis: `tactic_type` = `check` oder kombiniert mit Material-Hinweis. +- **Hanging Piece** + - Nach Bestzug ist eine gegnerische Figur angegriffen und unzureichend gedeckt. + - Ergebnis: `tactic_type` = `hanging_piece`. +- **Weiche Motive (optional/später)** + - Aktivitätsverbesserung, Outpost-Kontrolle, Königssicherheit. + - Ergebnis: `tactic_type` = `improve_activity` o. Ä. + +## Qualität, Priorisierung und Konfiguration +- **Konservativ** melden: lieber weniger Motive als falsche Treffer. +- **Priorisierung** (wenn mehrere Motive): Materialgewinn > Fork > Pin > Check > positionelle Motive. +- **Konfigurierbar**: Schwellwerte für Eval-Verlust, Materialdelta, Prioritätsregeln je Spielstärke. + +## Verhalten bei "nichts gefunden" +- Leere Motivliste oder `tactic_type = "none"` liefern. +- Damit kann das bestehende System weiterhin neutralere Hinweise geben (z. B. „Aktivierungschance verpasst“). + +## Integrationshinweise +- Modul wird zwischen Engine-Auswertung und LLM-Ausgabe aufgerufen. +- Nutzt bereitgestellte Bestzug- und PV-Daten; keine zusätzlichen Engine-Anfragen nötig. +- Output-Format so halten, dass LLM klar referenzieren kann, was mit dem Bestzug möglich gewesen wäre. diff --git a/src/components/ChessGame.tsx b/src/components/ChessGame.tsx index 402bd1e..e2e7789 100644 --- a/src/components/ChessGame.tsx +++ b/src/components/ChessGame.tsx @@ -15,6 +15,7 @@ import { GameAnalysisModal } from "./GameAnalysisModal"; import { GameOverModal, MoveHistoryItem } from "./GameOverModal"; import { Brain, ArrowLeft } from "lucide-react"; import { CapturedPieces } from "./CapturedPieces"; +import { detectMissedTactics, uciToSan } from "@/lib/tacticDetection"; interface ChessGameProps { initialFen?: string; @@ -328,6 +329,19 @@ export default function ChessGame({ initialFen, initialPgn, initialPersonality, // 5. Complete the history item with computer's move data (only if we have evalP0) if (partialHistoryItem && evalP0) { + const isWhite = playerColor === 'white'; + const evalBefore = isWhite ? evalP0.score : -evalP0.score; + const evalAfterPlayerMove = isWhite ? -p1Eval.score : p1Eval.score; + const cpLoss = evalBefore - evalAfterPlayerMove; + const bestMoveSan = uciToSan(fenP0, evalP0.bestMove); + const missedTactics = detectMissedTactics({ + fen: fenP0, + playerColor, + playerMoveSan: moveResult.result.san, + bestMoveUci: evalP0.bestMove, + cpLoss, + }); + const completeHistoryItem: MoveHistoryItem = { ...partialHistoryItem, computerMove: compResult.result.san, @@ -339,6 +353,9 @@ export default function ChessGame({ initialFen, initialPgn, initialPersonality, evalBefore: evalP0.score, evalAfter: p1Eval.score, bestMove: evalP0.bestMove, + bestMoveSan, + cpLoss, + missedTactics, }; setMoveHistory(prev => [...prev, completeHistoryItem]); } else { diff --git a/src/components/GameOverModal.tsx b/src/components/GameOverModal.tsx index af7aef1..9c4ed20 100644 --- a/src/components/GameOverModal.tsx +++ b/src/components/GameOverModal.tsx @@ -4,6 +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 ReactMarkdown from "react-markdown"; export interface MoveHistoryItem { @@ -29,6 +30,10 @@ export interface MoveHistoryItem { category?: 'inaccuracy' | 'mistake' | 'blunder'; cpLoss?: number; + // Missed tactical opportunities on the engine's best move + missedTactics?: DetectedTactic[]; + bestMoveSan?: string | null; + // Legacy fields for backward compatibility (deprecated) /** @deprecated Use playerMove instead */ move?: string; @@ -77,6 +82,9 @@ export function GameOverModal({ result, winner, history, apiKey, language, onClo let evalAfter: number; let playerMove: string; let bestMove: string | undefined; + let bestMoveSan: string | null | undefined; + let missedTactics = item.missedTactics; + let cpLoss: number | undefined = item.cpLoss; if (item.evalBeforePlayerMove && item.evalAfterPlayerMove) { // New enhanced format @@ -91,6 +99,7 @@ export function GameOverModal({ result, winner, history, apiKey, language, onClo playerMove = item.playerMove; bestMove = item.evalBeforePlayerMove.bestMove; + bestMoveSan = item.bestMoveSan; } else { // Legacy format (backward compatibility) evalBefore = item.evalBefore || 0; @@ -102,21 +111,24 @@ export function GameOverModal({ result, winner, history, apiKey, language, onClo // Calculate centipawn loss // Positive delta = position got worse for player const delta = evalBefore - evalAfter; + const cpLossValue = cpLoss ?? delta; let category: 'inaccuracy' | 'mistake' | 'blunder' | null = null; - if (delta >= 300) category = 'blunder'; - else if (delta >= 100) category = 'mistake'; - else if (delta >= 50) category = 'inaccuracy'; + if (cpLossValue >= 300) category = 'blunder'; + else if (cpLossValue >= 100) category = 'mistake'; + else if (cpLossValue >= 50) category = 'inaccuracy'; return { ...item, category, - cpLoss: delta, + cpLoss: cpLossValue, // Ensure legacy fields are populated for display move: playerMove, evalBefore: evalBefore, evalAfter: evalAfter, bestMove: bestMove, + bestMoveSan, + missedTactics, }; }).filter(item => item.category !== null) as MoveHistoryItem[]; @@ -130,9 +142,23 @@ export function GameOverModal({ result, winner, history, apiKey, language, onClo const mistakes = detectedMistakes.filter(m => m.category === 'mistake'); const inaccuracies = detectedMistakes.filter(m => m.category === 'inaccuracy'); - const mistakesText = detectedMistakes.map(m => - `Move ${m.moveNumber}: ${m.move} (${m.category?.toUpperCase()}: -${Math.round(m.cpLoss || 0)}cp loss, eval ${Math.round(m.evalBefore || 0)} → ${Math.round(m.evalAfter || 0)}). Best was: ${m.bestMove}` - ).join("\n"); + const describeTactics = (tactics?: DetectedTactic[]) => { + if (!tactics || tactics.length === 0) return ""; + const meaningful = tactics.filter(t => t.tactic_type !== 'none'); + if (meaningful.length === 0) return ""; + return meaningful.map(t => { + const material = t.material_delta ? ` (~${t.material_delta}cp)` : ''; + const pieces = t.piece_roles ? ` [${t.piece_roles.join(', ')}]` : ''; + return `${t.tactic_type}${material}${pieces}`; + }).join('; '); + }; + + const mistakesText = detectedMistakes.map(m => { + const tacticSummary = describeTactics(m.missedTactics); + const bestMoveDisplay = m.bestMoveSan || m.bestMove || 'N/A'; + const tacticNote = tacticSummary ? ` Tactics missed: ${tacticSummary}.` : ''; + return `Move ${m.moveNumber}: ${m.move} (${m.category?.toUpperCase()}: -${Math.round(m.cpLoss || 0)}cp loss, eval ${Math.round(m.evalBefore || 0)} → ${Math.round(m.evalAfter || 0)}). Best was: ${bestMoveDisplay}.${tacticNote}`; + }).join("\n"); // Build a complete game narrative for better LLM analysis const gameNarrative = history.map((item, idx) => { diff --git a/src/lib/tacticDetection.ts b/src/lib/tacticDetection.ts new file mode 100644 index 0000000..9b931e8 --- /dev/null +++ b/src/lib/tacticDetection.ts @@ -0,0 +1,365 @@ +import { Chess, Piece, Square } from "chess.js"; + +export type TacticType = + | "win_piece" + | "win_pawn" + | "pin" + | "fork" + | "skewer" + | "check" + | "hanging_piece" + | "none"; + +export type DetectedTactic = { + tactic_type: TacticType; + affected_squares?: Square[]; + piece_roles?: string[]; + material_delta?: number; + move: string; // SAN of the best move +}; + +export interface TacticDetectionInput { + fen: string; + playerColor: "white" | "black"; + playerMoveSan: string; + bestMoveUci: string; + cpLoss?: number; + evalLossThreshold?: number; +} + +const PIECE_VALUES: Record = { + p: 100, + n: 300, + b: 300, + r: 500, + q: 900, + k: 10000, +}; + +const FILES = ["a", "b", "c", "d", "e", "f", "g", "h"] as const; + +function coordsToSquare(file: number, rank: number): Square | null { + if (file < 0 || file > 7 || rank < 0 || rank > 7) return null; + return `${FILES[file]}${rank + 1}` as Square; +} + +function squareToCoords(square: Square): { file: number; rank: number } { + return { file: FILES.indexOf(square[0] as (typeof FILES)[number]), rank: parseInt(square[1]) - 1 }; +} + +function describePiece(piece: Piece | null): string | null { + if (!piece) return null; + const color = piece.color === "w" ? "white" : "black"; + const nameMap: Record = { + p: "pawn", + n: "knight", + b: "bishop", + r: "rook", + q: "queen", + k: "king", + }; + return `${color} ${nameMap[piece.type]}`; +} + +function uciToMove(uci: string) { + return { + from: uci.substring(0, 2), + to: uci.substring(2, 4), + promotion: uci.length > 4 ? uci.substring(4, 5) : undefined, + }; +} + +export function uciToSan(fen: string, uci: string): string | null { + const chess = new Chess(fen); + const move = chess.move(uciToMove(uci)); + return move ? move.san : null; +} + +function collectAttacks(chess: Chess, color: "white" | "black") { + const attackers = new Map(); + const squares: Square[] = []; + + for (let file = 0; file < 8; file++) { + for (let rank = 0; rank < 8; rank++) { + const square = coordsToSquare(file, rank); + if (!square) continue; + const piece = chess.get(square); + if (piece && piece.color === (color === "white" ? "w" : "b")) { + squares.push(square); + } + } + } + + for (const square of squares) { + for (const target of attackedSquaresFromPiece(chess, square)) { + if (!attackers.has(target)) attackers.set(target, []); + attackers.get(target)!.push(square); + } + } + + return attackers; +} + +function attackedSquaresFromPiece(chess: Chess, square: Square): Square[] { + const piece = chess.get(square); + if (!piece) return []; + const attacks: Square[] = []; + const deltas = { + n: [ + [1, 2], + [2, 1], + [2, -1], + [1, -2], + [-1, -2], + [-2, -1], + [-2, 1], + [-1, 2], + ], + k: [ + [1, 1], + [1, 0], + [1, -1], + [0, 1], + [0, -1], + [-1, 1], + [-1, 0], + [-1, -1], + ], + } as const; + + const colorForward = piece.color === "w" ? 1 : -1; + const { file, rank } = squareToCoords(square); + + if (piece.type === "n" || piece.type === "k") { + for (const [df, dr] of deltas[piece.type]) { + const target = coordsToSquare(file + df, rank + dr); + if (target) attacks.push(target); + } + return attacks; + } + + if (piece.type === "p") { + for (const df of [-1, 1]) { + const target = coordsToSquare(file + df, rank + colorForward); + if (target) attacks.push(target); + } + return attacks; + } + + const directions: number[][] = []; + if (piece.type === "b" || piece.type === "q") { + directions.push([1, 1], [1, -1], [-1, 1], [-1, -1]); + } + if (piece.type === "r" || piece.type === "q") { + directions.push([1, 0], [-1, 0], [0, 1], [0, -1]); + } + + for (const [df, dr] of directions) { + let step = 1; + while (true) { + const target = coordsToSquare(file + df * step, rank + dr * step); + if (!target) break; + attacks.push(target); + const occupier = chess.get(target); + if (occupier) break; + step++; + } + } + + return attacks; +} + +function detectCheck(chessAfter: Chess): DetectedTactic[] { + if (chessAfter.inCheck()) { + const lastMove = chessAfter.history({ verbose: true }).slice(-1)[0]; + const moveSan = lastMove?.san ?? ""; + return [ + { + tactic_type: "check", + affected_squares: lastMove?.to ? [lastMove.to as Square] : undefined, + piece_roles: lastMove?.piece ? [describePiece({ color: lastMove.color, type: lastMove.piece } as Piece)!] : undefined, + move: moveSan, + }, + ]; + } + return []; +} + +function detectCapture(chessAfter: Chess, moveSan: string, cpThreshold: number): DetectedTactic[] { + const lastMove = chessAfter.history({ verbose: true }).slice(-1)[0]; + if (!lastMove || !lastMove.captured) return []; + + const capturedValue = PIECE_VALUES[lastMove.captured as Piece["type"]]; + if (capturedValue < cpThreshold) return []; + + // Conservative safety: ensure opponent has no immediate legal capture on the landing square + const immediateCounter = chessAfter.moves({ verbose: true }).filter(m => m.to === lastMove.to && m.flags.includes("c")); + if (immediateCounter.length > 0) return []; + + const tactic: DetectedTactic = { + tactic_type: capturedValue >= 200 ? "win_piece" : "win_pawn", + affected_squares: lastMove.to ? [lastMove.to as Square] : undefined, + piece_roles: [ + describePiece({ color: lastMove.color, type: lastMove.piece } as Piece)!, + describePiece({ color: lastMove.color === "w" ? "b" : "w", type: lastMove.captured } as Piece)!, + ], + material_delta: capturedValue, + move: moveSan, + }; + + return [tactic]; +} + +function detectPinsAndSkewers(chessAfter: Chess, moverColor: "white" | "black", moveSan: string): DetectedTactic[] { + const results: DetectedTactic[] = []; + const mover = moverColor === "white" ? "w" : "b"; + const opponent = moverColor === "white" ? "b" : "w"; + const slidingTypes: Piece["type"][] = ["b", "r", "q"]; + const directions: Record = { + b: [[1, 1], [1, -1], [-1, 1], [-1, -1]], + r: [[1, 0], [-1, 0], [0, 1], [0, -1]], + q: [[1, 1], [1, -1], [-1, 1], [-1, -1], [1, 0], [-1, 0], [0, 1], [0, -1]], + n: [], + k: [], + p: [], + }; + + for (const file of FILES) { + for (let rank = 1; rank <= 8; rank++) { + const square = `${file}${rank}` as Square; + const piece = chessAfter.get(square); + if (!piece || piece.color !== mover || !slidingTypes.includes(piece.type)) continue; + + for (const [df, dr] of directions[piece.type]) { + let step = 1; + let firstBlocked: { square: Square; piece: Piece } | null = null; + let secondBlocked: { square: Square; piece: Piece } | null = null; + const baseFile = FILES.indexOf(file); + const baseRank = rank - 1; + + while (true) { + const next = coordsToSquare(baseFile + df * step, baseRank + dr * step); + if (!next) break; + const occupier = chessAfter.get(next); + if (occupier) { + if (!firstBlocked) { + firstBlocked = { square: next, piece: occupier }; + } else { + secondBlocked = { square: next, piece: occupier }; + break; + } + } + step++; + } + + if (!firstBlocked || !secondBlocked) continue; + + if (firstBlocked.piece.color === opponent && secondBlocked.piece.color === opponent) { + const firstValue = PIECE_VALUES[firstBlocked.piece.type]; + const secondValue = PIECE_VALUES[secondBlocked.piece.type]; + + if (secondBlocked.piece.type === "k" || secondValue > firstValue) { + results.push({ + tactic_type: "pin", + affected_squares: [firstBlocked.square, secondBlocked.square], + piece_roles: [describePiece(piece)!, describePiece(firstBlocked.piece)!, describePiece(secondBlocked.piece)!], + move: moveSan, + }); + } else if (firstValue > secondValue && firstValue >= 500) { + results.push({ + tactic_type: "skewer", + affected_squares: [firstBlocked.square, secondBlocked.square], + piece_roles: [describePiece(piece)!, describePiece(firstBlocked.piece)!, describePiece(secondBlocked.piece)!], + move: moveSan, + }); + } + } + } + } + } + + return results; +} + +function detectFork(chessAfter: Chess, moverColor: "white" | "black", moveSan: string): DetectedTactic[] { + const lastMove = chessAfter.history({ verbose: true }).slice(-1)[0]; + if (!lastMove?.to) return []; + const targetSquare = lastMove.to as Square; + const mover = moverColor === "white" ? "w" : "b"; + const attackedSquares = attackedSquaresFromPiece(chessAfter, targetSquare); + const threatenedValuables = attackedSquares + .map(square => ({ square, piece: chessAfter.get(square) })) + .filter(item => item.piece && item.piece.color !== mover) + .map(item => ({ square: item.square, piece: item.piece as Piece, value: PIECE_VALUES[(item.piece as Piece).type] })) + .filter(item => item.value >= 300) + .sort((a, b) => b.value - a.value); + if (threatenedValuables.length < 2) return []; + + const pieceDescriptions = threatenedValuables.slice(0, 2).map(v => describePiece(v.piece)!).filter(Boolean); + return [ + { + tactic_type: "fork", + affected_squares: threatenedValuables.slice(0, 2).map(v => v.square), + piece_roles: pieceDescriptions, + move: moveSan, + }, + ]; +} + +function detectHangingPieces(chessAfter: Chess, moverColor: "white" | "black", moveSan: string): DetectedTactic[] { + const moverAttackers = collectAttacks(chessAfter, moverColor); + const opponentColor = moverColor === "white" ? "black" : "white"; + const opponentAttackers = collectAttacks(chessAfter, opponentColor); + const results: DetectedTactic[] = []; + + for (const [square, attackers] of moverAttackers) { + const targetPiece = chessAfter.get(square); + if (!targetPiece || targetPiece.color === (moverColor === "white" ? "w" : "b")) continue; + + const defenders = opponentAttackers.get(square) || []; + if (attackers.length > 0 && defenders.length === 0) { + results.push({ + tactic_type: "hanging_piece", + affected_squares: [square], + piece_roles: [describePiece(targetPiece)!], + material_delta: PIECE_VALUES[targetPiece.type], + move: moveSan, + }); + } + } + + return results; +} + +export function detectMissedTactics({ + fen, + playerColor, + playerMoveSan, + bestMoveUci, + cpLoss, + evalLossThreshold = 50, +}: TacticDetectionInput): DetectedTactic[] { + if (cpLoss !== undefined && cpLoss < evalLossThreshold) return []; + + const bestMoveSan = uciToSan(fen, bestMoveUci); + if (!bestMoveSan || bestMoveSan === playerMoveSan) return []; + + const chess = new Chess(fen); + const move = chess.move(uciToMove(bestMoveUci)); + if (!move) return []; + + const chessAfter = chess; // already has move applied + const detectionResults: DetectedTactic[] = []; + + detectionResults.push(...detectCapture(chessAfter, move.san, 50)); + detectionResults.push(...detectCheck(chessAfter)); + detectionResults.push(...detectPinsAndSkewers(chessAfter, playerColor, move.san)); + 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 detectionResults; +}