From 743bda02bf6b6e6874dfd0f64bcdbad87b26473e Mon Sep 17 00:00:00 2001 From: stefan-kp <65659186+stefan-kp@users.noreply.github.com> Date: Sat, 6 Dec 2025 09:23:04 +0100 Subject: [PATCH] Add tactic position generators and tests --- fixtures/README.md | 54 +++ fixtures/gambits.json | 32 ++ fixtures/tactics/back_rank_weakness.json | 22 + fixtures/tactics/discovered_check.json | 23 + fixtures/tactics/double_attack.json | 22 + fixtures/tactics/fork.json | 22 + fixtures/tactics/overloading.json | 22 + fixtures/tactics/pin.json | 23 + fixtures/tactics/skewer.json | 23 + fixtures/tactics/trapped_piece.json | 22 + src/lib/__tests__/tacticGenerators.test.ts | 59 +++ src/lib/tacticalLibrary.ts | 520 +++++++++++++++++++++ 12 files changed, 844 insertions(+) create mode 100644 fixtures/README.md create mode 100644 fixtures/gambits.json create mode 100644 fixtures/tactics/back_rank_weakness.json create mode 100644 fixtures/tactics/discovered_check.json create mode 100644 fixtures/tactics/double_attack.json create mode 100644 fixtures/tactics/fork.json create mode 100644 fixtures/tactics/overloading.json create mode 100644 fixtures/tactics/pin.json create mode 100644 fixtures/tactics/skewer.json create mode 100644 fixtures/tactics/trapped_piece.json create mode 100644 src/lib/__tests__/tacticGenerators.test.ts create mode 100644 src/lib/tacticalLibrary.ts diff --git a/fixtures/README.md b/fixtures/README.md new file mode 100644 index 0000000..72c574e --- /dev/null +++ b/fixtures/README.md @@ -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/.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. diff --git a/fixtures/gambits.json b/fixtures/gambits.json new file mode 100644 index 0000000..172a8e0 --- /dev/null +++ b/fixtures/gambits.json @@ -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"] + } + ] +} diff --git a/fixtures/tactics/back_rank_weakness.json b/fixtures/tactics/back_rank_weakness.json new file mode 100644 index 0000000..954fc67 --- /dev/null +++ b/fixtures/tactics/back_rank_weakness.json @@ -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"] + } + ] +} diff --git a/fixtures/tactics/discovered_check.json b/fixtures/tactics/discovered_check.json new file mode 100644 index 0000000..5b3f8b2 --- /dev/null +++ b/fixtures/tactics/discovered_check.json @@ -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"] + } + ] +} diff --git a/fixtures/tactics/double_attack.json b/fixtures/tactics/double_attack.json new file mode 100644 index 0000000..41faa8b --- /dev/null +++ b/fixtures/tactics/double_attack.json @@ -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"] + } + ] +} diff --git a/fixtures/tactics/fork.json b/fixtures/tactics/fork.json new file mode 100644 index 0000000..bf1e7ae --- /dev/null +++ b/fixtures/tactics/fork.json @@ -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"] + } + ] +} diff --git a/fixtures/tactics/overloading.json b/fixtures/tactics/overloading.json new file mode 100644 index 0000000..123a080 --- /dev/null +++ b/fixtures/tactics/overloading.json @@ -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"] + } + ] +} diff --git a/fixtures/tactics/pin.json b/fixtures/tactics/pin.json new file mode 100644 index 0000000..ffc8026 --- /dev/null +++ b/fixtures/tactics/pin.json @@ -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"] + } + ] +} diff --git a/fixtures/tactics/skewer.json b/fixtures/tactics/skewer.json new file mode 100644 index 0000000..7993fa8 --- /dev/null +++ b/fixtures/tactics/skewer.json @@ -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"] + } + ] +} diff --git a/fixtures/tactics/trapped_piece.json b/fixtures/tactics/trapped_piece.json new file mode 100644 index 0000000..d5a2a66 --- /dev/null +++ b/fixtures/tactics/trapped_piece.json @@ -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"] + } + ] +} diff --git a/src/lib/__tests__/tacticGenerators.test.ts b/src/lib/__tests__/tacticGenerators.test.ts new file mode 100644 index 0000000..ea65fdb --- /dev/null +++ b/src/lib/__tests__/tacticGenerators.test.ts @@ -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(); + }, + ); +}); + diff --git a/src/lib/tacticalLibrary.ts b/src/lib/tacticalLibrary.ts new file mode 100644 index 0000000..ae77d8d --- /dev/null +++ b/src/lib/tacticalLibrary.ts @@ -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 = { 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 { + const attacks = new Map(); + 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> = { + 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 = []; + 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);