Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,14 @@ Chessian automatically detects chess openings, game outcomes, time variants, and
Chessian enriches raw PGN data with intelligent detection:

#### Opening Detection
- Analyzes move sequences against complete ECO database (A–E, 4000+ variations)
- Detects the **deepest matching opening** for any game
- Prefers PGN **Opening** and **ECO** tags when available
- Falls back to analyzing move sequences against the complete ECO database (A–E, 4000+ variations)
- Uses exact move-order matching first, then a conservative transposition fallback for common alternative move orders
- Extracts:
- **Opening**: Full descriptive name (e.g., "Sicilian Defense")
- **Variation**: Specific variation branch (e.g., "Najdorf Variation")
- **ECO**: Standard classification code (e.g., "B90")
- *Note: Detection is based on move sequences, not PGN tags. Handles transpositions correctly.*
- *Note: PGN headers are treated as the source of truth. Move-based detection is used when those headers are missing.*

> **Opening Detection Disclaimer**
> Openings are detected using the Lichess open ECO database and move-based analysis.
Expand Down Expand Up @@ -227,7 +228,9 @@ root
│ └─ e5 (Open Game)
...
```
For each game, moves are traversed; the deepest matching opening is used.
For each game, Chessian first uses PGN `Opening` and `ECO` tags when present.
When those tags are missing, moves are traversed through the tree and the deepest exact match is used.
If the exact path only finds an early match, a side-aware transposition fallback can prefer a deeper ECO line reached through a common alternative move order.

### 2.1 Linkable Opening Selection
The settings tab derives a unique list of opening names from the ECO database.
Expand Down
60 changes: 58 additions & 2 deletions src/openings/eco/tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import type { OpeningEntry, OpeningTreeNode } from "./types";
import { ECO_DATA } from "./eco_index";

export function buildOpeningTree(): OpeningTreeNode {
const root: OpeningTreeNode = { children: {} };
const root: OpeningTreeNode = {
children: {},
transpositionIndex: {},
maxIndexedPly: 0
};

for (const entry of ECO_DATA) {
let node = root;
Expand All @@ -16,12 +20,33 @@ export function buildOpeningTree(): OpeningTreeNode {

// Assign opening to the deepest node
node.opening = entry;

root.transpositionIndex![getTranspositionKey(entry.pgn)] = entry;
root.maxIndexedPly = Math.max(root.maxIndexedPly ?? 0, entry.pgn.length);
}

return root;
}

export function detectOpening(moves: string[], tree: OpeningTreeNode) {
function getTranspositionKey(moves: string[]): string {
const whiteMoves: string[] = [];
const blackMoves: string[] = [];

moves.forEach((move, index) => {
if (index % 2 === 0) {
whiteMoves.push(move);
} else {
blackMoves.push(move);
}
});

return [
whiteMoves.sort().join(","),
blackMoves.sort().join(",")
].join("|");
}

function detectExactOpening(moves: string[], tree: OpeningTreeNode): OpeningEntry | null {
let node = tree;
let bestMatch: OpeningEntry | null = null;

Expand All @@ -38,3 +63,34 @@ export function detectOpening(moves: string[], tree: OpeningTreeNode) {
return bestMatch;
}

function detectTransposedOpening(moves: string[], tree: OpeningTreeNode): OpeningEntry | null {
if (!tree.transpositionIndex || !tree.maxIndexedPly) return null;

let bestMatch: OpeningEntry | null = null;
const maxPly = Math.min(moves.length, tree.maxIndexedPly);

for (let ply = 1; ply <= maxPly; ply++) {
const match = tree.transpositionIndex[getTranspositionKey(moves.slice(0, ply))];

if (match && (!bestMatch || match.pgn.length > bestMatch.pgn.length)) {
bestMatch = match;
}
}

return bestMatch;
}

export function detectOpening(moves: string[], tree: OpeningTreeNode) {
const exactMatch = detectExactOpening(moves, tree);
const transposedMatch = detectTransposedOpening(moves, tree);

if (
transposedMatch &&
(!exactMatch || transposedMatch.pgn.length > exactMatch.pgn.length)
) {
return transposedMatch;
}

return exactMatch;
}

4 changes: 3 additions & 1 deletion src/openings/eco/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,6 @@ export interface OpeningEntry {
export interface OpeningTreeNode {
children: Record<string, OpeningTreeNode>;
opening?: OpeningEntry; // assigned when a full line matches
}
transpositionIndex?: Record<string, OpeningEntry>;
maxIndexedPly?: number;
}
4 changes: 2 additions & 2 deletions src/pgn/enrich.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ export function enrichPGN(
): EnrichedPGN {
const match = detectOpening(normalizeMovesForOpeningDetection(parsed.moves), tree);

const openingName = match?.name ?? null;
const openingName = parsed.opening ?? match?.name ?? null;
const { base, variation } = splitOpeningName(openingName);

const linkable = base ? settings.linkableOpenings.includes(base) : false;
Expand All @@ -287,7 +287,7 @@ export function enrichPGN(
openingLinkable: linkable,
openingBaseLinked: linkOpening(base, linkable),

eco: match?.eco ?? parsed.eco ?? null,
eco: parsed.eco ?? match?.eco ?? null,
variant: inferVariant(parsed.timeControl),
outcome: outcome ?? parsed.result,
termination: terminationType,
Expand Down
2 changes: 2 additions & 0 deletions src/pgn/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export interface ParsedPGN {
black: string | null;
blackElo: string | null;
result: string | null;
opening: string | null;
eco: string | null;
date: string | null;
timeControl: string | null;
Expand Down Expand Up @@ -56,6 +57,7 @@ export function parsePGN(raw: string): ParsedPGN {
black: tags["Black"] || null,
blackElo: tags["BlackElo"] || null,
result: tags["Result"] || null,
opening: tags["Opening"] || null,
eco: tags["ECO"] || null,
date: tags["Date"] || null,
timeControl: tags["TimeControl"] || null,
Expand Down
54 changes: 54 additions & 0 deletions tests/openings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,60 @@ describe("opening tree", () => {
name: "French Defense"
});
});

it("detects Queen's Gambit Declined through common d4/e6 transposition", () => {
const tree = buildOpeningTree();

expect(detectOpening(["d4", "e6", "c4", "d5"], tree)).toMatchObject({
eco: "D30",
name: "Queen's Gambit Declined"
});
});

it("keeps the canonical Queen's Gambit Declined move order working", () => {
const tree = buildOpeningTree();

expect(detectOpening(["d4", "d5", "c4", "e6"], tree)).toMatchObject({
eco: "D30",
name: "Queen's Gambit Declined"
});
});

it("detects Slav Defense through English/Anglo-Slav transposition", () => {
const tree = buildOpeningTree();

expect(detectOpening(["c4", "c6", "d4", "d5"], tree)).toMatchObject({
eco: "D10",
name: "Slav Defense"
});
});

it("detects Catalan Opening through c4/g3 transposition", () => {
const tree = buildOpeningTree();

expect(detectOpening(["d4", "Nf6", "g3", "e6", "c4"], tree)).toMatchObject({
eco: "E00",
name: "Catalan Opening"
});
});

it("detects Horwitz Defense because of incorrect moves", () => {
const tree = buildOpeningTree();

expect(detectOpening(["d4", "e6", "Rg6", "Ra3"], tree)).toMatchObject({
eco: "A40",
name: "Horwitz Defense"
});
});

it("detects Horwitz Defense because of lack of moves", () => {
const tree = buildOpeningTree();

expect(detectOpening(["d4", "e6"], tree)).toMatchObject({
eco: "A40",
name: "Horwitz Defense"
});
});
});

describe("linkable opening utilities", () => {
Expand Down
26 changes: 26 additions & 0 deletions tests/pgn-enrich.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ const testTree: OpeningTreeNode = {
name: "Sicilian Defense",
pgn: ["e4", "c5"]
}
},
e5: {
children: {},
opening: {
eco: "C20",
name: "King's Pawn Game",
pgn: ["e4", "e5"]
}
}
}
}
Expand Down Expand Up @@ -102,6 +110,24 @@ describe("enrichPGN", () => {
expect(enriched.movesCount).toBe(2);
});

it("prefers PGN Opening and ECO headers over fallback detection", () => {
const enriched = enrichPGN(
parsePGN(`
[Opening "Queen's Gambit Declined"]
[ECO "D30"]
[Result "*"]

1. e4 e5 *
`),
testTree,
settings({})
);

expect(enriched.opening).toBe("Queen's Gambit Declined");
expect(enriched.openingBase).toBe("Queen's Gambit Declined");
expect(enriched.eco).toBe("D30");
});

it("detects chess.com player perspective for black wins", () => {
const enriched = enrichPGN(
parsePGN(CHESSCOM_BLACK_WIN_PGN),
Expand Down
14 changes: 14 additions & 0 deletions tests/pgn-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,19 @@ describe("parsePGN", () => {
]);
});

it("extracts Opening and ECO tags when present", () => {
const parsed = parsePGN(`
[Opening "Queen's Gambit Declined"]
[ECO "D30"]
[Result "*"]

1. d4 e6 2. c4 d5 *
`);

expect(parsed.opening).toBe("Queen's Gambit Declined");
expect(parsed.eco).toBe("D30");
});

it("removes comments, NAGs, and move numbers while preserving SAN tokens", () => {
const parsed = parsePGN(ANNOTATED_PGN);

Expand Down Expand Up @@ -61,6 +74,7 @@ describe("parsePGN", () => {

expect(parsed.white).toBeNull();
expect(parsed.black).toBeNull();
expect(parsed.opening).toBeNull();
expect(parsed.result).toBeNull();
expect(parsed.moves).toEqual(["d4", "Nf6", "c4", "e6", "*"]);
});
Expand Down
Loading