Implement tactical detection module
This commit is contained in:
@@ -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.
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<Piece["type"], number> = {
|
||||
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<Piece["type"], string> = {
|
||||
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<Square, Square[]>();
|
||||
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<Piece["type"], number[][]> = {
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user