diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 782f836..a214016 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -4,33 +4,33 @@ on: push: branches: [master] paths: - - 'package.json' - - 'src/**' - - 'methods/**' + - "package.json" + - "src/**" + - "methods/**" jobs: publish: runs-on: ubuntu-latest - + steps: - uses: actions/checkout@v4 - + - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '18' - cache: 'npm' - registry-url: 'https://registry.npmjs.org' - + node-version: "18" + cache: "npm" + registry-url: "https://registry.npmjs.org" + - name: Install dependencies run: npm ci - + - name: Type check run: npm run type-check - + - name: Build run: npm run build - + - name: Check if version changed id: version-check run: | @@ -45,13 +45,13 @@ jobs: echo "changed=false" >> $GITHUB_OUTPUT echo "Version unchanged at $PACKAGE_VERSION" fi - + - name: Publish to NPM if: steps.version-check.outputs.changed == 'true' run: npm publish --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - + - name: Create Git tag if: steps.version-check.outputs.changed == 'true' run: | diff --git a/README.md b/README.md index 1cf65c7..7717965 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,73 @@ const opening = findOpening(openings, fen, posBook); **Returns:** `Opening | undefined` +#### `lookupByMoves(chess, openingBook, options?)` + +Finds an opening by walking backward through move history. Works with any chess library implementing the `ChessGameLike` interface (chess.js, @chess-pgn/chess-pgn, etc.). + +This function tries to find an opening match at the current position. If no match is found, it undoes moves one by one and tries again, allowing it to find the nearest named opening even when the current position has moved beyond standard opening theory. + +```typescript +import { ChessPGN } from "@chess-pgn/chess-pgn"; +import { + openingBook, + lookupByMoves, + getPositionBook, +} from "@chess-openings/eco.json"; + +const chess = new ChessPGN(); +chess.loadPgn("1. e4 e5 2. Nf3 Nc6 3. Bb5 a6 4. Ba4 Nf6"); + +const openings = await openingBook(); +const posBook = getPositionBook(openings); + +const result = lookupByMoves(chess, openings, { positionBook: posBook }); + +console.log(result.opening?.name); // "Ruy Lopez" +console.log(result.movesBack); // 0 (exact match at current position) +``` + +**Parameters:** + +- `chess: ChessGameLike` - Chess game instance (must have `fen()`, `undo()`, `load()` methods) +- `openingBook: OpeningCollection` - The opening database +- `options?: LookupByMovesOptions` + - `maxMovesBack?: number` - Maximum moves to walk backward (default: 50 plies / move 25) + - `positionBook?: PositionBook` - Position book for better matching + +**Returns:** `LookupByMovesResult` + +- `opening: Opening | undefined` - The opening found (or undefined) +- `movesBack: number` - Number of moves walked backward (0 = exact match) + +**ChessGameLike Interface:** + +Your chess library must implement: + +```typescript +interface ChessGameLike { + fen(): string; // Returns current FEN + undo(): unknown; // Undoes last move (returns truthy on success, falsy/throws when no moves) + load(fen: string): void; // Loads position from FEN +} +``` + +Compatible libraries: chess.js, @chess-pgn/chess-pgn, and others with similar APIs. + +**Example - Position beyond opening theory:** + +```typescript +// Many moves deep into a game +chess.loadPgn( + "1. e4 e5 2. Nf3 Nc6 3. Bb5 a6 4. Ba4 Nf6 5. O-O Be7 6. Re1 b5 7. Bb3 d6 8. c3 O-O 9. h3 Na5" +); + +const result = lookupByMoves(chess, openings, { maxMovesBack: 10 }); + +console.log(result.opening?.name); // "Ruy Lopez: Closed" +console.log(result.movesBack); // 3 (walked back 3 moves to find opening) +``` + #### `getPositionBook(openingBook)` Creates a position-only FEN → full FEN array mapping. diff --git a/examples/lookupByMoves.ts b/examples/lookupByMoves.ts new file mode 100644 index 0000000..63280a1 --- /dev/null +++ b/examples/lookupByMoves.ts @@ -0,0 +1,68 @@ +/** + * Example usage of lookupByMoves with @chess-pgn/chess-pgn + * + * This demonstrates how to find chess openings from move sequences + * using the backward search algorithm. + */ + +import { ChessPGN } from "@chess-pgn/chess-pgn"; +import { + openingBook, + lookupByMoves, + getPositionBook, +} from "@chess-openings/eco.json"; + +async function main() { + // Load opening data + const openings = await openingBook(); + const posBook = getPositionBook(openings); + + // Example 1: Exact opening match + const chess1 = new ChessPGN(); + chess1.loadPgn("1. e4 e5 2. Nf3 Nc6 3. Bb5"); + + const result1 = lookupByMoves(chess1, openings, { positionBook: posBook }); + console.log("Example 1 - Ruy Lopez:"); + console.log(` Opening: ${result1.opening?.name}`); + console.log(` ECO: ${result1.opening?.eco}`); + console.log(` Moves back: ${result1.movesBack}`); + console.log(); + + // Example 2: Position beyond opening theory + const chess2 = new ChessPGN(); + chess2.loadPgn( + "1. e4 e5 2. Nf3 Nc6 3. Bb5 a6 4. Ba4 Nf6 5. O-O Be7 6. Re1 b5 7. Bb3 d6 8. c3 O-O 9. h3 Na5" + ); + + const result2 = lookupByMoves(chess2, openings, { + positionBook: posBook, + maxMovesBack: 10, + }); + console.log("Example 2 - Deep in Ruy Lopez:"); + console.log(` Opening: ${result2.opening?.name}`); + console.log(` ECO: ${result2.opening?.eco}`); + console.log(` Moves back: ${result2.movesBack}`); + console.log(); + + // Example 3: Uncommon opening + const chess3 = new ChessPGN(); + chess3.loadPgn("1. f4 e5 2. fxe5 d6 3. exd6 Bxd6"); + + const result3 = lookupByMoves(chess3, openings, { positionBook: posBook }); + console.log("Example 3 - From Gambit:"); + console.log(` Opening: ${result3.opening?.name}`); + console.log(` ECO: ${result3.opening?.eco}`); + console.log(` Moves back: ${result3.movesBack}`); + console.log(); + + // Example 4: Non-opening position + const chess4 = new ChessPGN(); + chess4.loadPgn("1. a3 b6 2. b4 c5 3. c3 d5"); + + const result4 = lookupByMoves(chess4, openings, { positionBook: posBook }); + console.log("Example 4 - Rare line:"); + console.log(` Opening: ${result4.opening?.name || "Not found"}`); + console.log(` Moves back: ${result4.movesBack}`); +} + +main().catch(console.error); diff --git a/methods/findOpening.ts b/methods/findOpening.ts index 2156c6a..6153699 100644 --- a/methods/findOpening.ts +++ b/methods/findOpening.ts @@ -87,3 +87,138 @@ export function findOpening( return opening; } + +/** + * Minimal interface that chess libraries must implement to use lookupByMoves. + * Compatible with chess.js, @chess-pgn/chess-pgn, and similar libraries. + */ +export interface ChessGameLike { + /** Returns the current position as a FEN string */ + fen(): string; + + /** Undoes the last move. Returns null/false/throws when no moves to undo. */ + undo(): unknown; + + /** Loads a position from FEN */ + load(fen: string): void; +} + +/** + * Result of looking up an opening by moves + */ +export interface LookupByMovesResult { + /** The opening found, or undefined if no opening matches */ + opening: Opening | undefined; + + /** Number of moves walked backward to find the opening (0 = exact match) */ + movesBack: number; +} + +/** + * Options for lookupByMoves + */ +export interface LookupByMovesOptions { + /** Maximum number of moves to walk backward (default: 50 plies / move 25) */ + maxMovesBack?: number; + + /** Position book for fallback matching (optional, improves match rate) */ + positionBook?: PositionBook; +} + +/** + * Finds a chess opening by walking backward through move history. + * Works with any chess library implementing the ChessGameLike interface. + * + * This function tries to find an opening match at the current position. + * If no match is found, it undoes moves one by one and tries again, + * allowing it to find the nearest named opening even when the current + * position has moved beyond standard opening theory. + * + * The chess game state is restored to its original position after the search. + * + * @param chess - A chess game instance (chess.js, chessPGN, etc.) + * @param openingBook - The opening collection to search + * @param options - Search options (maxMovesBack, positionBook) + * @returns Result containing the opening and number of moves walked back + * + * @example + * ```typescript + * import { ChessPGN } from '@chess-pgn/chess-pgn'; + * import { openingBook, lookupByMoves } from '@chess-openings/eco.json'; + * + * const chess = new ChessPGN(); + * chess.loadPgn('1. e4 e5 2. Nf3 Nc6 3. Bb5 a6 4. Ba4 Nf6'); + * + * const result = lookupByMoves(chess, await openingBook()); + * console.log(result.opening?.name); // "Ruy Lopez" + * console.log(result.movesBack); // 0 (exact match) + * ``` + * + * @example + * ```typescript + * // Position beyond standard opening theory + * chess.loadPgn('1. e4 e5 2. Nf3 Nc6 3. Bb5 a6 4. Ba4 Nf6 5. O-O Be7 6. Re1 b5 7. Bb3 d6 8. c3 O-O 9. h3 Na5'); + * + * const result = lookupByMoves(chess, await openingBook(), { maxMovesBack: 10 }); + * console.log(result.opening?.name); // "Ruy Lopez: Closed" + * console.log(result.movesBack); // 3 (walked back 3 moves to find opening) + * ``` + */ +export function lookupByMoves( + chess: ChessGameLike, + openingBook: OpeningCollection, + options?: LookupByMovesOptions +): LookupByMovesResult { + const startingFen = chess.fen(); + const startPly = options?.maxMovesBack ?? 50; // Start searching at ply 50 (move 25) + + try { + // Extract move number and turn from FEN (e.g., "rnbq... w KQkq - 0 45") + const fenParts = startingFen.split(" "); + const moveNumber = parseInt(fenParts[5] || "1", 10); + const isWhiteTurn = fenParts[1] === "w"; + + // Calculate current ply: (moveNumber - 1) * 2 + (isWhiteTurn ? 0 : 1) + const currentPly = (moveNumber - 1) * 2 + (isWhiteTurn ? 0 : 1); + + // If game is longer than startPly, undo back to startPly + if (currentPly > startPly) { + for (let i = 0; i < currentPly - startPly; i++) { + chess.undo(); + } + } + + // Now walk backward from startPly (or current position if shorter) + let movesBack = currentPly > startPly ? currentPly - startPly : 0; + + while (true) { + const opening = findOpening( + openingBook, + chess.fen(), + options?.positionBook + ); + + if (opening) { + return { opening, movesBack }; + } + + // Try to undo - if it fails/returns falsy, no more moves + const undoResult = chess.undo(); + if (undoResult === null || undoResult === false) { + break; + } + + movesBack++; + } + + // No opening found + return { opening: undefined, movesBack: 0 }; + } catch (e) { + // Some libraries throw when undo() is called with no moves + // Return no opening found + return { opening: undefined, movesBack: 0 }; + } finally { + // Always restore original position + chess.load(startingFen); + } +} diff --git a/package-lock.json b/package-lock.json index ba57fc1..af52548 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,20 +1,31 @@ { "name": "@chess-openings/eco.json", - "version": "1.0.3", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@chess-openings/eco.json", - "version": "1.0.3", + "version": "1.1.0", "license": "MIT", "devDependencies": { + "@chess-pgn/chess-pgn": "^1.0.1", "typescript": "^5.3.0" }, "engines": { "node": ">=18.0.0" } }, + "node_modules/@chess-pgn/chess-pgn": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@chess-pgn/chess-pgn/-/chess-pgn-1.0.1.tgz", + "integrity": "sha512-yFYuuluFhQBdzA9cQjcEfmoue2sJF8CiCKl+YL3EqZ4vmRydUDKZWusjg9ijBPJddLlELIJmt3ctmvZZkmlmTw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=22.15.0" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/package.json b/package.json index 98b8a3e..0c19479 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chess-openings/eco.json", - "version": "1.0.3", + "version": "1.1.0", "description": "Read-only utilities for consuming chess opening data from eco.json", "type": "module", "main": "./dist/src/index.js", @@ -41,6 +41,7 @@ }, "homepage": "https://github.com/JeffML/eco.json#readme", "devDependencies": { + "@chess-pgn/chess-pgn": "^1.0.1", "typescript": "^5.3.0" }, "engines": { diff --git a/src/index.ts b/src/index.ts index dc20510..299f692 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,7 +10,11 @@ export { getLatestEcoJson, openingBook } from "../methods/getLatestEcoJson.js"; export { findOpening, getPositionBook, + lookupByMoves, type PositionBook, + type ChessGameLike, + type LookupByMovesResult, + type LookupByMovesOptions, } from "../methods/findOpening.js"; export { getFromToIndex,