Add tactic position generators and tests

This commit is contained in:
stefan-kp
2025-12-06 09:23:04 +01:00
parent 1b2159445d
commit 743bda02bf
12 changed files with 844 additions and 0 deletions
+54
View File
@@ -0,0 +1,54 @@
# Tactical and Gambit Fixture Sets
This directory collects JSON fixtures for automated regression tests and training data generation.
Each file contains narrowly scoped cases for a single tactical motif or gambit family so that Jest
or other tooling can load them directly without hard-coding FEN strings in test suites.
## File naming
- `tactics/<pattern>.json` Examples for one tactical motif. Each file follows the same schema.
- `gambits.json` Openings that should be recognised by move-sequence matching.
## Common schema
Every tactical case shares the following shape:
```json
{
"patternType": "PIN",
"description": "Short explanation of the motif and what the move achieves.",
"cases": [
{
"id": "unique-string",
"initialFen": "...",
"sideToMove": "white",
"bestMove": { "san": "Bb5", "uci": "f1b5" },
"resultingFen": "...",
"expectedPattern": {
"type": "PIN",
"side": "white",
"attackerSquares": ["b5"],
"targetSquares": ["c6"],
"keySquares": ["e8"],
"explanationKey": "pin_to_king"
},
"context": "Optional PGN fragment or free-text rationale.",
"tags": ["opening", "classical"]
}
]
}
```
This layout aligns with the `TacticalPattern` interface defined in the library contract and keeps
enough metadata for round-trip testing: the move that produces the tactic, the resulting position,
and the expected pattern payload.
## Using the fixtures in tests
- Load the JSON files and iterate through `cases` to drive `detectTactics`,
`findTacticalOpportunitiesForSide`, or higher-level exercise generators.
- The `initialFen` represents the position **before** the critical move is played.
- Apply `bestMove` to reach `resultingFen`, then assert that the reported pattern matches
`expectedPattern`.
## Adding more cases
When adding new fixtures, prefer minimal positions that isolate the intended motif so failures
are easy to debug. Include a short `context` note so the scenario can be recreated or visualised
quickly when a test fails.
+32
View File
@@ -0,0 +1,32 @@
{
"gambits": [
{
"id": "kings_gambit",
"name": "King's Gambit",
"ecoCodes": ["C30", "C31", "C32", "C33"],
"side": "white",
"moveSequenceSan": ["e4", "e5", "f4"]
},
{
"id": "evans_gambit",
"name": "Evans Gambit",
"ecoCodes": ["C51"],
"side": "white",
"moveSequenceSan": ["e4", "e5", "Nf3", "Nc6", "Bc4", "Bc5", "b4"]
},
{
"id": "smith_morra_gambit",
"name": "Smith-Morra Gambit",
"ecoCodes": ["B21"],
"side": "white",
"moveSequenceSan": ["e4", "c5", "d4", "cxd4", "c3"]
},
{
"id": "benko_gambit",
"name": "Benko Gambit",
"ecoCodes": ["A57", "A58", "A59"],
"side": "black",
"moveSequenceSan": ["d4", "Nf6", "c4", "c5", "d5", "b5"]
}
]
}
+22
View File
@@ -0,0 +1,22 @@
{
"patternType": "BACK_RANK_WEAKNESS",
"description": "Classic back-rank mate with no escape squares for the king.",
"cases": [
{
"id": "back_rank_rook_mate",
"initialFen": "6k1/5ppp/8/8/8/8/5PPP/R5K1 w - - 0 1",
"sideToMove": "white",
"bestMove": { "san": "Ra8#", "uci": "a1a8" },
"resultingFen": "R5k1/5ppp/8/8/8/8/5PPP/6K1 b - - 1 1",
"expectedPattern": {
"type": "BACK_RANK_WEAKNESS",
"side": "white",
"attackerSquares": ["a8"],
"targetSquares": ["g8"],
"explanationKey": "back_rank_mate"
},
"context": "No luft and the king is boxed in; the rook delivers mate on the back rank.",
"tags": ["mate", "fundamental"]
}
]
}
+23
View File
@@ -0,0 +1,23 @@
{
"patternType": "DISCOVERED_CHECK",
"description": "Bishop steps aside to reveal a rook check along the open file.",
"cases": [
{
"id": "discovered_check_rook_on_open_file",
"initialFen": "4k3/8/8/8/8/8/4B3/4R1K1 w - - 0 1",
"sideToMove": "white",
"bestMove": { "san": "Bb5+", "uci": "e2b5" },
"resultingFen": "4k3/8/8/1B6/8/8/8/4R1K1 b - - 1 1",
"expectedPattern": {
"type": "DISCOVERED_CHECK",
"side": "white",
"attackerSquares": ["e1"],
"targetSquares": ["e8"],
"keySquares": ["b5"],
"explanationKey": "rook_file_discovery"
},
"context": "Moving the bishop from e2 to b5 exposes the rook on e1, delivering check on the e-file.",
"tags": ["discovery", "rook_file"]
}
]
}
+22
View File
@@ -0,0 +1,22 @@
{
"patternType": "DOUBLE_ATTACK",
"description": "Queen move that checks the king while targeting an undefended rook.",
"cases": [
{
"id": "double_attack_queen_check_and_rook",
"initialFen": "r3k3/8/8/8/8/8/8/3Q2K1 w - - 0 1",
"sideToMove": "white",
"bestMove": { "san": "Qa4+", "uci": "d1a4" },
"resultingFen": "r3k3/8/8/8/Q7/8/8/6K1 b - - 1 1",
"expectedPattern": {
"type": "DOUBLE_ATTACK",
"side": "white",
"attackerSquares": ["a4"],
"targetSquares": ["e8", "a8"],
"explanationKey": "queen_check_and_rook"
},
"context": "A single queen move gives check on e8 and simultaneously eyes the rook on a8.",
"tags": ["queen", "double_attack"]
}
]
}
+22
View File
@@ -0,0 +1,22 @@
{
"patternType": "FORK",
"description": "Knight fork hitting king and queen simultaneously.",
"cases": [
{
"id": "fork_knight_checks_king_and_queen",
"initialFen": "4k3/5q2/8/5N2/8/8/8/6K1 w - - 0 1",
"sideToMove": "white",
"bestMove": { "san": "Nd6+", "uci": "f5d6" },
"resultingFen": "4k3/5q2/3N4/8/8/8/8/6K1 b - - 1 1",
"expectedPattern": {
"type": "FORK",
"side": "white",
"attackerSquares": ["d6"],
"targetSquares": ["e8", "f7"],
"explanationKey": "knight_fork"
},
"context": "The knight hop gives check on e8 while simultaneously attacking the queen on f7.",
"tags": ["tactics", "knight"]
}
]
}
+22
View File
@@ -0,0 +1,22 @@
{
"patternType": "OVERLOADING",
"description": "The defending rook on f8 is overloaded, forced to choose between guarding the back rank and protecting f7.",
"cases": [
{
"id": "overloading_rook_on_f8",
"initialFen": "5rk1/5p2/8/3Q4/8/8/8/4R1K1 w - - 0 1",
"sideToMove": "white",
"bestMove": { "san": "Qxf7+", "uci": "d5f7" },
"resultingFen": "5rk1/5Q2/8/8/8/8/8/4R1K1 b - - 0 1",
"expectedPattern": {
"type": "OVERLOADING",
"side": "white",
"attackerSquares": ["f7"],
"targetSquares": ["f8", "f7"],
"explanationKey": "back_rank_vs_defense"
},
"context": "Capturing on f7 lures the only defender; after ...Rxf7 the back rank collapses to Re8#.",
"tags": ["overloading", "back_rank_setup"]
}
]
}
+23
View File
@@ -0,0 +1,23 @@
{
"patternType": "PIN",
"description": "Classical bishop pin on the Ruy Lopez diagonal, tying the c6-knight to the e8-king.",
"cases": [
{
"id": "pin_ruy_lopez_knight_to_king",
"initialFen": "r1bqkbnr/pppp1ppp/2n5/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R w KQkq - 2 3",
"sideToMove": "white",
"bestMove": { "san": "Bb5", "uci": "f1b5" },
"resultingFen": "r1bqkbnr/pppp1ppp/2n5/1B2p3/4P3/5N2/PPPP1PPP/RNBQK2R b KQkq - 3 3",
"expectedPattern": {
"type": "PIN",
"side": "white",
"attackerSquares": ["b5"],
"targetSquares": ["c6"],
"keySquares": ["e8"],
"explanationKey": "pin_to_king"
},
"context": "1.e4 e5 2.Nf3 Nc6 3.Bb5 with the knight pinned to the king.",
"tags": ["opening", "ruy_lopez"]
}
]
}
+23
View File
@@ -0,0 +1,23 @@
{
"patternType": "SKEWER",
"description": "Bishop skewer forcing the king off the diagonal so the trailing queen can be captured.",
"cases": [
{
"id": "skewer_bishop_on_long_diagonal",
"initialFen": "7q/6k1/8/8/8/8/1B6/6K1 w - - 0 1",
"sideToMove": "white",
"bestMove": { "san": "Bc3+", "uci": "b2c3" },
"resultingFen": "7q/6k1/8/8/8/2B5/8/6K1 b - - 1 1",
"expectedPattern": {
"type": "SKEWER",
"side": "white",
"attackerSquares": ["c3"],
"targetSquares": ["g7", "h8"],
"keySquares": ["g7"],
"explanationKey": "king_in_front_of_queen"
},
"context": "Minimal material: the move Bc3+ attacks the king on g7; after the king moves the queen on h8 is lost.",
"tags": ["endgame", "long_diagonal"]
}
]
}
+22
View File
@@ -0,0 +1,22 @@
{
"patternType": "TRAPPED_PIECE",
"description": "Illustrates a bishop trapped in the corner with no safe squares.",
"cases": [
{
"id": "trapped_corner_bishop",
"initialFen": "6kb/6P1/5K2/8/8/8/8/8 w - - 0 1",
"sideToMove": "white",
"bestMove": { "san": "gxh8=Q+", "uci": "g7h8q" },
"resultingFen": "6kQ/8/5K2/8/8/8/8/8 b - - 0 1",
"expectedPattern": {
"type": "TRAPPED_PIECE",
"side": "white",
"attackerSquares": ["g7"],
"targetSquares": ["h8"],
"explanationKey": "corner_trap"
},
"context": "The black bishop on h8 cannot escape because of the pawn on g7 and the king on f6.",
"tags": ["endgame", "conversion"]
}
]
}
@@ -0,0 +1,59 @@
import {
Color,
GeneratedTacticPosition,
TacticalPatternType,
detectTacticsForSide,
findTacticalOpportunitiesForSide,
generateBackRankWeaknessPosition,
generateDiscoveredCheckPosition,
generateDoubleAttackPosition,
generateForkPosition,
generateOverloadingPosition,
generatePinPosition,
generateSkewerPosition,
generateTrappedPiecePosition,
} from "../tacticalLibrary";
type GeneratorEntry = {
patternType: TacticalPatternType;
side: Color;
generator: () => GeneratedTacticPosition;
};
const generators: GeneratorEntry[] = [
{ patternType: "PIN", side: "white", generator: () => generatePinPosition({ side: "white" }) },
{ patternType: "SKEWER", side: "white", generator: () => generateSkewerPosition({ side: "white" }) },
{ patternType: "FORK", side: "white", generator: () => generateForkPosition({ side: "white" }) },
{ patternType: "DISCOVERED_CHECK", side: "white", generator: () => generateDiscoveredCheckPosition({ side: "white" }) },
{ patternType: "DOUBLE_ATTACK", side: "white", generator: () => generateDoubleAttackPosition({ side: "white" }) },
{ patternType: "OVERLOADING", side: "white", generator: () => generateOverloadingPosition({ side: "white" }) },
{ patternType: "BACK_RANK_WEAKNESS", side: "black", generator: () => generateBackRankWeaknessPosition({ side: "black" }) },
{ patternType: "TRAPPED_PIECE", side: "black", generator: () => generateTrappedPiecePosition({ side: "black" }) },
];
describe("tactic generators", () => {
it.each(generators)(
"creates scenarios where opportunities expose %s",
({ patternType, side, generator }) => {
const scenario = generator();
const opportunities = findTacticalOpportunitiesForSide(scenario.initialPosition, side);
const opportunity = opportunities.find(
(o) =>
o.move.from === scenario.creatingMove.from &&
o.move.to === scenario.creatingMove.to &&
o.pattern.type === patternType,
);
expect(opportunity).toBeDefined();
expect(opportunity?.pattern).toMatchObject({ type: patternType });
const resultingPatterns = detectTacticsForSide(scenario.resultingPosition, side);
const directMatch = resultingPatterns.find((p) => p.type === patternType);
expect(directMatch).toBeDefined();
expect(scenario.resultingPosition.fen).toBeTruthy();
},
);
});
+520
View File
@@ -0,0 +1,520 @@
import { Chess, Color as ChessJsColor, Move as ChessMove, Piece, Square } from "chess.js";
import gambitDefinitions from "../../fixtures/gambits.json";
import pinFixtures from "../../fixtures/tactics/pin.json";
import skewerFixtures from "../../fixtures/tactics/skewer.json";
import forkFixtures from "../../fixtures/tactics/fork.json";
import discoveredCheckFixtures from "../../fixtures/tactics/discovered_check.json";
import doubleAttackFixtures from "../../fixtures/tactics/double_attack.json";
import overloadingFixtures from "../../fixtures/tactics/overloading.json";
import backRankFixtures from "../../fixtures/tactics/back_rank_weakness.json";
import trappedPieceFixtures from "../../fixtures/tactics/trapped_piece.json";
export type Color = "white" | "black";
export interface Position {
fen: string;
}
export interface Move {
from: string;
to: string;
promotion?: "q" | "r" | "b" | "n";
}
export type TacticalPatternType =
| "PIN"
| "SKEWER"
| "FORK"
| "DISCOVERED_ATTACK"
| "DISCOVERED_CHECK"
| "DOUBLE_ATTACK"
| "OVERLOADING"
| "BACK_RANK_WEAKNESS"
| "TRAPPED_PIECE";
export interface TacticalPattern {
type: TacticalPatternType;
side: Color;
attackerSquares: string[];
targetSquares: string[];
keySquares?: string[];
explanationKey?: string;
}
export interface GambleDefinition {
id: string;
name: string;
ecoCodes: string[];
side: Color;
moveSequenceSan: string[];
}
export interface GambitMatch {
gambitId: string;
name: string;
side: Color;
matchedMoves: number;
isExactLine: boolean;
}
export interface TacticalOpportunity {
move: Move;
pattern: TacticalPattern;
}
export interface TacticalRisk {
move: Move;
opponentPatterns: TacticalPattern[];
}
export interface TacticExerciseParams {
patternType: TacticalPatternType;
side: Color;
maxDepthFromInitial?: number;
}
export interface TacticExercise {
startPosition: Position;
solutionMove: Move;
resultPosition: Position;
pattern: TacticalPattern;
}
export interface GeneratedTacticPosition {
patternType: TacticalPatternType;
side: Color;
initialPosition: Position;
creatingMove: Move;
resultingPosition: Position;
expectedPattern: TacticalPattern;
}
export interface GenerateOptions {
side?: Color;
caseId?: string;
}
export interface EngineAdapter {
evaluate(position: Position): number;
}
export interface LibraryConfig {
engine?: EngineAdapter;
}
const FILES = ["a", "b", "c", "d", "e", "f", "g", "h"] as const;
const pieceValues: Record<Piece["type"], number> = { p: 100, n: 300, b: 300, r: 500, q: 900, k: 10000 } as const;
const tacticalFixtures = {
PIN: pinFixtures,
SKEWER: skewerFixtures,
FORK: forkFixtures,
DISCOVERED_CHECK: discoveredCheckFixtures,
DISCOVERED_ATTACK: discoveredCheckFixtures,
DOUBLE_ATTACK: doubleAttackFixtures,
OVERLOADING: overloadingFixtures,
BACK_RANK_WEAKNESS: backRankFixtures,
TRAPPED_PIECE: trappedPieceFixtures,
};
type PieceInfo = { square: Square; piece: Piece };
function attackMap(chess: Chess, side: Color): Map<Square, Square[]> {
const attacks = new Map<Square, Square[]>();
for (const { square } of collectPieces(chess, side)) {
const moves = chess.moves({ square, verbose: true }) as ChessMove[];
for (const move of moves) {
const target = move.to as Square;
if (!attacks.has(target)) attacks.set(target, []);
attacks.get(target)!.push(square);
}
}
return attacks;
}
function toChessColor(side: Color): ChessJsColor {
return side === "white" ? "w" : "b";
}
function collectPieces(chess: Chess, side: Color): PieceInfo[] {
const result: PieceInfo[] = [];
chess.board().forEach((rank, rankIndex) => {
rank.forEach((piece, fileIndex) => {
if (piece && piece.color === toChessColor(side)) {
const square = `${FILES[fileIndex]}${8 - rankIndex}` as Square;
result.push({ square, piece });
}
});
});
return result;
}
function raySquares(from: Square, df: number, dr: number): Square[] {
const squares: Square[] = [];
const fileIndex = FILES.indexOf(from[0] as (typeof FILES)[number]);
const rankIndex = parseInt(from[1], 10) - 1;
let step = 1;
while (true) {
const file = fileIndex + df * step;
const rank = rankIndex + dr * step;
if (file < 0 || file > 7 || rank < 0 || rank > 7) break;
squares.push(`${FILES[file]}${rank + 1}` as Square);
step++;
}
return squares;
}
function valuable(piece: Piece | null, threshold = 300) {
return piece ? pieceValues[piece.type] >= threshold : false;
}
function detectPinsAndSkewers(chess: Chess, side: Color): TacticalPattern[] {
const results: TacticalPattern[] = [];
const opponent: Color = side === "white" ? "black" : "white";
const sliders = collectPieces(chess, side).filter(p => ["b", "r", "q"].includes(p.piece.type));
const directions: Record<Piece["type"], Array<[number, 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],
],
k: [],
n: [],
p: [],
};
for (const slider of sliders) {
for (const [df, dr] of directions[slider.piece.type]) {
const ray = raySquares(slider.square, df, dr);
const seen: Array<PieceInfo & { color: Color }> = [];
for (const square of ray) {
const occupier = chess.get(square);
if (occupier) {
seen.push({ square, piece: occupier, color: occupier.color === "w" ? "white" : "black" });
if (seen.length === 2) break;
}
}
if (seen.length < 2) continue;
const [first, second] = seen;
if (first.color !== opponent || second.color !== opponent) continue;
const firstValue = pieceValues[first.piece.type];
const secondValue = pieceValues[second.piece.type];
const isPin = second.piece.type === "k" || secondValue > firstValue;
const isSkewer = firstValue > secondValue && firstValue >= 300;
if (isPin) {
results.push({ type: "PIN", side, attackerSquares: [slider.square], targetSquares: [first.square], keySquares: [second.square] });
} else if (isSkewer) {
results.push({ type: "SKEWER", side, attackerSquares: [slider.square], targetSquares: [first.square, second.square] });
}
}
}
return results;
}
function squaresAttackedBy(chess: Chess, from: Square): Square[] {
const moves = chess.moves({ square: from, verbose: true }) as ChessMove[];
return moves.map(m => m.to as Square);
}
function detectFork(chess: Chess, side: Color): TacticalPattern[] {
const patterns: TacticalPattern[] = [];
for (const { square } of collectPieces(chess, side)) {
const attacks = squaresAttackedBy(chess, square);
const valuableTargets = attacks
.map(target => ({ target, piece: chess.get(target) }))
.filter(item => item.piece && item.piece.color !== toChessColor(side) && valuable(item.piece))
.sort((a, b) => pieceValues[(b.piece as Piece).type] - pieceValues[(a.piece as Piece).type]);
if (valuableTargets.length >= 2) {
patterns.push({
type: "FORK",
side,
attackerSquares: [square],
targetSquares: valuableTargets.slice(0, 2).map(v => v.target),
});
}
}
return patterns;
}
function detectDiscovered(chess: Chess, side: Color): TacticalPattern[] {
const patterns: TacticalPattern[] = [];
const sliders = collectPieces(chess, side).filter(p => ["b", "r", "q"].includes(p.piece.type));
const opponent: Color = side === "white" ? "black" : "white";
for (const slider of sliders) {
const directions: Array<[number, number]> = slider.piece.type === "b"
? [[1, 1], [1, -1], [-1, 1], [-1, -1]]
: slider.piece.type === "r"
? [[1, 0], [-1, 0], [0, 1], [0, -1]]
: [[1, 1], [1, -1], [-1, 1], [-1, -1], [1, 0], [-1, 0], [0, 1], [0, -1]];
for (const [df, dr] of directions) {
const ray = raySquares(slider.square, df, dr);
let blocker: Square | null = null;
let target: Square | null = null;
for (const sq of ray) {
const occupier = chess.get(sq);
if (!occupier) continue;
if (occupier.color === toChessColor(side)) {
blocker = sq;
} else {
target = sq;
break;
}
}
if (blocker && target) {
const targetPiece = chess.get(target);
if (!targetPiece) continue;
const type: TacticalPatternType = targetPiece.type === "k" ? "DISCOVERED_CHECK" : "DISCOVERED_ATTACK";
patterns.push({ type, side, attackerSquares: [slider.square], targetSquares: [target], keySquares: [blocker] });
}
}
}
return patterns;
}
function detectDoubleAttack(chess: Chess, side: Color): TacticalPattern[] {
const opponentPieces = collectPieces(chess, side === "white" ? "black" : "white");
const attacks = attackMap(chess, side);
const threatenedTargets = opponentPieces
.filter(op => valuable(op.piece) && attacks.has(op.square))
.map(op => op.square);
if (threatenedTargets.length >= 2) {
return [{ type: "DOUBLE_ATTACK", side, attackerSquares: [], targetSquares: threatenedTargets }];
}
return [];
}
function detectOverloading(chess: Chess, side: Color): TacticalPattern[] {
const patterns: TacticalPattern[] = [];
const opponent: Color = side === "white" ? "black" : "white";
const opponentPieces = collectPieces(chess, opponent);
const opponentDefenses = attackMap(chess, opponent);
const attackerMap = attackMap(chess, side);
for (const defender of opponentPieces) {
const defendedSquares = (opponentDefenses.get(defender.square) || []).filter(sq => {
const piece = chess.get(sq);
return piece && piece.color === toChessColor(opponent) && valuable(piece);
});
if (defendedSquares.length >= 2 && attackerMap.has(defender.square)) {
patterns.push({ type: "OVERLOADING", side, attackerSquares: attackerMap.get(defender.square) || [], targetSquares: defendedSquares });
}
}
return patterns;
}
function detectBackRankWeakness(chess: Chess, side: Color): TacticalPattern[] {
const opponent: Color = side === "white" ? "black" : "white";
const king = collectPieces(chess, opponent).find(p => p.piece.type === "k");
if (!king) return [];
const kingRank = parseInt(king.square[1], 10);
const backRank = opponent === "white" ? 1 : 8;
if (kingRank !== backRank) return [];
const escapeFiles = [FILES.indexOf(king.square[0] as (typeof FILES)[number]) - 1, FILES.indexOf(king.square[0] as (typeof FILES)[number]), FILES.indexOf(king.square[0] as (typeof FILES)[number]) + 1].filter(i => i >= 0 && i < 8);
const escapeSquares = escapeFiles.map(f => `${FILES[f]}${backRank + (opponent === "white" ? 1 : -1)}` as Square);
const blocked = escapeSquares.every(sq => chess.get(sq));
if (!blocked) return [];
const attackers = collectPieces(chess, side).filter(p => ["q", "r"].includes(p.piece.type));
for (const attacker of attackers) {
const sameFile = attacker.square[0] === king.square[0];
const sameRank = attacker.square[1] === king.square[1];
if (!sameFile && !sameRank) continue;
const df = sameFile ? 0 : attacker.square[0] < king.square[0] ? 1 : -1;
const dr = sameRank ? 0 : attacker.square[1] < king.square[1] ? 1 : -1;
const pathSquares = raySquares(attacker.square, df, dr);
let blockedByOpponent = false;
for (const sq of pathSquares) {
if (sq === king.square) break;
if (chess.get(sq)) {
blockedByOpponent = true;
break;
}
}
if (!blockedByOpponent) {
return [{ type: "BACK_RANK_WEAKNESS", side, attackerSquares: [attacker.square], targetSquares: [king.square] }];
}
}
return [];
}
function detectTrappedPieces(chess: Chess, side: Color): TacticalPattern[] {
const opponent: Color = side === "white" ? "black" : "white";
const patterns: TacticalPattern[] = [];
for (const pieceInfo of collectPieces(chess, opponent)) {
if (pieceInfo.piece.type === "k") continue;
const moves = chess.moves({ square: pieceInfo.square, verbose: true }) as ChessMove[];
if (moves.length === 0) {
patterns.push({ type: "TRAPPED_PIECE", side, attackerSquares: [], targetSquares: [pieceInfo.square] });
}
}
return patterns;
}
export function detectTactics(position: Position): TacticalPattern[] {
return [
...detectTacticsForSide(position, "white"),
...detectTacticsForSide(position, "black"),
];
}
export function detectTacticsForSide(position: Position, side: Color): TacticalPattern[] {
const chess = new Chess(position.fen);
return [
...detectPinsAndSkewers(chess, side),
...detectFork(chess, side),
...detectDiscovered(chess, side),
...detectDoubleAttack(chess, side),
...detectOverloading(chess, side),
...detectBackRankWeakness(chess, side),
...detectTrappedPieces(chess, side),
];
}
export function findTacticalOpportunitiesForSide(position: Position, side: Color): TacticalOpportunity[] {
const chess = new Chess(position.fen);
const opportunities: TacticalOpportunity[] = [];
const moves = chess.moves({ verbose: true }) as ChessMove[];
for (const move of moves) {
if (move.color !== toChessColor(side)) continue;
const clone = new Chess(position.fen);
clone.move(move);
const patterns = detectTacticsForSide({ fen: clone.fen() }, side);
for (const pattern of patterns) {
opportunities.push({
move: { from: move.from, to: move.to, promotion: move.promotion as Move["promotion"] | undefined },
pattern,
});
}
}
return opportunities;
}
export function findRiskyMoves(position: Position, side: Color): TacticalRisk[] {
const chess = new Chess(position.fen);
const risks: TacticalRisk[] = [];
const moves = chess.moves({ verbose: true }) as ChessMove[];
const opponent: Color = side === "white" ? "black" : "white";
for (const move of moves) {
if (move.color !== toChessColor(side)) continue;
const clone = new Chess(position.fen);
clone.move(move);
const opponentPatterns = findTacticalOpportunitiesForSide({ fen: clone.fen() }, opponent).map(o => o.pattern);
if (opponentPatterns.length > 0) {
risks.push({
move: { from: move.from, to: move.to, promotion: move.promotion as Move["promotion"] | undefined },
opponentPatterns,
});
}
}
return risks;
}
export function identifyGambit(movesSan: string[]): GambitMatch | null {
const matches = listPossibleGambits(movesSan);
return matches.length > 0 ? matches[0] : null;
}
export function listPossibleGambits(movesSan: string[]): GambitMatch[] {
const definitions = gambitDefinitions as GambleDefinition[];
const matches: GambitMatch[] = [];
for (const gambit of definitions) {
let matched = 0;
for (let i = 0; i < Math.min(movesSan.length, gambit.moveSequenceSan.length); i++) {
if (movesSan[i] !== gambit.moveSequenceSan[i]) break;
matched++;
}
if (matched > 0) {
matches.push({
gambitId: gambit.id,
name: gambit.name,
side: gambit.side,
matchedMoves: matched,
isExactLine: matched === gambit.moveSequenceSan.length && matched === movesSan.length,
});
}
}
return matches.sort((a, b) => b.matchedMoves - a.matchedMoves);
}
export function generateTacticExercise(params: TacticExerciseParams): TacticExercise {
const dataset = tacticalFixtures[params.patternType];
const cases = dataset.cases.filter((c: any) => c.sideToMove === params.side);
const chosen = cases[0] || dataset.cases[0];
if (!chosen) {
throw new Error(`No fixture available for pattern ${params.patternType}`);
}
return {
startPosition: { fen: chosen.initialFen },
solutionMove: {
from: chosen.bestMove.uci.substring(0, 2),
to: chosen.bestMove.uci.substring(2, 4),
promotion: chosen.bestMove.uci.length > 4 ? chosen.bestMove.uci.substring(4, 5) : undefined,
},
resultPosition: { fen: chosen.resultingFen },
pattern: chosen.expectedPattern,
};
}
function pickFixtureCase(patternType: TacticalPatternType, options: GenerateOptions = {}) {
const dataset = tacticalFixtures[patternType];
if (!dataset?.cases) {
throw new Error(`No fixtures registered for pattern ${patternType}`);
}
const filtered = options.side ? dataset.cases.filter((c: any) => c.sideToMove === options.side) : dataset.cases;
const match = options.caseId ? filtered.find((c: any) => c.id === options.caseId) : null;
const pool = filtered.length > 0 ? filtered : dataset.cases;
if (pool.length === 0) {
throw new Error(`No cases available for pattern ${patternType}`);
}
const chosen = match || pool[Math.floor(Math.random() * pool.length)];
return chosen;
}
export function generateTacticPosition(
patternType: TacticalPatternType,
options: GenerateOptions = {}
): GeneratedTacticPosition {
const chosen = pickFixtureCase(patternType, options);
const moveUci: string = chosen.bestMove.uci;
const creatingMove: Move = {
from: moveUci.substring(0, 2),
to: moveUci.substring(2, 4),
promotion: moveUci.length > 4 ? (moveUci.substring(4, 5) as Move["promotion"]) : undefined,
};
return {
patternType,
side: chosen.sideToMove as Color,
initialPosition: { fen: chosen.initialFen },
creatingMove,
resultingPosition: { fen: chosen.resultingFen },
expectedPattern: chosen.expectedPattern,
};
}
export const generatePinPosition = (options: GenerateOptions = {}) => generateTacticPosition("PIN", options);
export const generateSkewerPosition = (options: GenerateOptions = {}) => generateTacticPosition("SKEWER", options);
export const generateForkPosition = (options: GenerateOptions = {}) => generateTacticPosition("FORK", options);
export const generateDiscoveredCheckPosition = (options: GenerateOptions = {}) => generateTacticPosition("DISCOVERED_CHECK", options);
export const generateDoubleAttackPosition = (options: GenerateOptions = {}) => generateTacticPosition("DOUBLE_ATTACK", options);
export const generateOverloadingPosition = (options: GenerateOptions = {}) => generateTacticPosition("OVERLOADING", options);
export const generateBackRankWeaknessPosition = (options: GenerateOptions = {}) => generateTacticPosition("BACK_RANK_WEAKNESS", options);
export const generateTrappedPiecePosition = (options: GenerateOptions = {}) => generateTacticPosition("TRAPPED_PIECE", options);