Implement tactical detection module

This commit is contained in:
stefan-kp
2025-11-26 15:16:08 +01:00
parent b817182a8b
commit f0e95ae1a0
4 changed files with 489 additions and 7 deletions
+365
View File
@@ -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;
}