Add tactic position generators and tests
This commit is contained in:
@@ -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.
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user