From 878d469eb07986e769d54fd25373937408ff9b3e Mon Sep 17 00:00:00 2001 From: Stefan Date: Thu, 27 Nov 2025 20:21:45 +0100 Subject: [PATCH] feat: Improve opening detection with hybrid exact + continuation approach - Return exact match first (e.g., King's Pawn Game for 1.e4 e5) - Fill remaining slots with common continuations (Ruy Lopez, Italian Game, etc.) - Look up to 4 moves deeper to find important opening variations - Prioritize by depth (closer continuations first), then ECO root flag, then ECO code - Avoid duplicate ECO families (e.g., don't show multiple C6x openings) - Add isEcoRoot field to OpeningMetadata interface - Add test script to verify opening lookup logic Example: After 1.e4 e5, now returns: 1. King's Pawn Game (C20) - exact match 2. King's Gambit (C30) - continuation 3. King's Knight Opening (C40) - continuation 4. Ruy Lopez (C60) - continuation 5. Italian Game (C50) - continuation This gives users context about what openings are still possible. --- scripts/testOpeningLookup.js | 93 +++++++++++++++++++++++++++++++++++ src/lib/openings.ts | 94 +++++++++++++++++++++++++++++++++--- 2 files changed, 181 insertions(+), 6 deletions(-) create mode 100644 scripts/testOpeningLookup.js diff --git a/scripts/testOpeningLookup.js b/scripts/testOpeningLookup.js new file mode 100644 index 0000000..910db16 --- /dev/null +++ b/scripts/testOpeningLookup.js @@ -0,0 +1,93 @@ +#!/usr/bin/env node + +/** + * Test script for opening lookup functionality + * Tests the hybrid approach: exact matches + common continuations + */ + +const moveIndex = require('../public/openings/moveIndex.json'); + +function lookupPossibleOpenings(moveSequence, maxResults = 5) { + const normalized = moveSequence.trim(); + const results = []; + const seenEcoRoots = new Set(); + + // Step 1: Add exact matches first + const exactMatches = moveIndex[normalized] || []; + for (const opening of exactMatches) { + if (results.length >= maxResults) break; + results.push(opening); + const ecoRoot = opening.eco.substring(0, 2); + seenEcoRoots.add(ecoRoot); + } + + // Step 2: If we haven't reached maxResults, look for common continuations + if (results.length < maxResults) { + const continuations = []; + + for (const [seq, openings] of Object.entries(moveIndex)) { + if (seq.startsWith(normalized + ' ')) { + const ourPly = normalized.split(/\s+/).filter(s => s && !s.match(/^\d+\.$/)).length; + const theirPly = seq.split(/\s+/).filter(s => s && !s.match(/^\d+\.$/)).length; + const depth = theirPly - ourPly; + + if (depth > 0 && depth <= 4) { + continuations.push({ sequence: seq, openings, depth }); + } + } + } + + continuations.sort((a, b) => { + if (a.depth !== b.depth) return a.depth - b.depth; + + const aHasRoot = a.openings.some(o => o.isEcoRoot); + const bHasRoot = b.openings.some(o => o.isEcoRoot); + if (aHasRoot && !bHasRoot) return -1; + if (!aHasRoot && bHasRoot) return 1; + + const aEco = a.openings[0]?.eco || 'ZZZ'; + const bEco = b.openings[0]?.eco || 'ZZZ'; + return aEco.localeCompare(bEco); + }); + + for (const { openings } of continuations) { + if (results.length >= maxResults) break; + + const opening = openings[0]; + if (opening) { + const ecoRoot = opening.eco.substring(0, 2); + if (!seenEcoRoots.has(ecoRoot)) { + results.push(opening); + seenEcoRoots.add(ecoRoot); + } + } + } + } + + return results.slice(0, maxResults); +} + +// Test cases +console.log('๐Ÿงช Testing Opening Lookup\n'); + +const testCases = [ + '1. e4', + '1. e4 e5', + '1. e4 c5', + '1. d4', + '1. e4 e5 2. Nf3', + '1. e4 e5 2. Nf3 Nc6 3. Bb5' +]; + +for (const moveSeq of testCases) { + console.log(`\n๐Ÿ“ Move sequence: "${moveSeq}"`); + const openings = lookupPossibleOpenings(moveSeq, 5); + console.log(` Found ${openings.length} opening(s):`); + openings.forEach((o, i) => { + const type = moveIndex[moveSeq]?.includes(o) ? '[EXACT]' : '[CONTINUATION]'; + console.log(` ${i + 1}. ${type} ${o.name} (${o.eco}) - ${o.moves}`); + }); +} + +console.log('\nโœ… Test complete!\n'); + diff --git a/src/lib/openings.ts b/src/lib/openings.ts index 296727b..3bc3ee1 100644 --- a/src/lib/openings.ts +++ b/src/lib/openings.ts @@ -10,6 +10,7 @@ export interface OpeningMetadata { eco: string; moves: string; name: string; + isEcoRoot?: boolean; aliases?: { [key: string]: string }; meta?: { strengths_white?: string[]; @@ -38,7 +39,9 @@ export function lookupOpening(fen: string): OpeningMetadata | null { /** * Lookup possible openings from a move sequence. - * Returns up to maxResults openings that match the given move sequence. + * Returns up to maxResults openings using a hybrid approach: + * 1. First, returns exact matches for the given sequence + * 2. Then, fills remaining slots with common continuations (one move deeper) * * @param moveSequence - The move sequence in SAN notation (e.g., "1. e4 c5 2. Nf3") * @param maxResults - Maximum number of results to return (default: 5) @@ -50,17 +53,96 @@ export function lookupOpening(fen: string): OpeningMetadata | null { * // Returns: [{ eco: "B00", name: "King's Pawn Game", ... }] * * @example - * // After 1.e4 c5 - * const openings = lookupPossibleOpenings("1. e4 c5", 5); - * // Returns: [{ eco: "B20", name: "Sicilian Defense", ... }, ...] + * // After 1.e4 e5 + * const openings = lookupPossibleOpenings("1. e4 e5", 5); + * // Returns: [ + * // { eco: "C20", name: "King's Pawn Game", ... }, // exact match + * // { eco: "C60", name: "Ruy Lopez", ... }, // continuation + * // { eco: "C50", name: "Italian Game", ... }, // continuation + * // ... + * // ] */ export function lookupPossibleOpenings( moveSequence: string, maxResults: number = 5 ): OpeningMetadata[] { const normalized = moveSequence.trim(); - const matches = (moveIndex as Record)[normalized] || []; - return matches.slice(0, maxResults); + const index = moveIndex as Record; + + const results: OpeningMetadata[] = []; + const seenEcoRoots = new Set(); // Track ECO roots to avoid duplicates + + // Step 1: Add exact matches first + const exactMatches = index[normalized] || []; + for (const opening of exactMatches) { + if (results.length >= maxResults) break; + results.push(opening); + // Track ECO root to avoid duplicate families + const ecoRoot = opening.eco.substring(0, 2); // e.g., "C20" -> "C2" + seenEcoRoots.add(ecoRoot); + } + + // Step 2: If we haven't reached maxResults, look for common continuations + if (results.length < maxResults) { + // Find all sequences that start with our sequence + const continuations: Array<{ + sequence: string; + openings: OpeningMetadata[]; + depth: number; // How many moves deeper than our sequence + }> = []; + + for (const [seq, openings] of Object.entries(index)) { + // Check if this sequence starts with our sequence + if (seq.startsWith(normalized + ' ')) { + // Count the number of half-moves (ply) + const ourPly = normalized.split(/\s+/).filter(s => s && !s.match(/^\d+\.$/)).length; + const theirPly = seq.split(/\s+/).filter(s => s && !s.match(/^\d+\.$/)).length; + const depth = theirPly - ourPly; + + // We want sequences that are 1-4 moves deeper (to catch important openings) + if (depth > 0 && depth <= 4) { + continuations.push({ sequence: seq, openings, depth }); + } + } + } + + // Sort continuations by: + // 1. Depth (prefer closer continuations) + // 2. Whether they have isEcoRoot flag (prioritize root openings) + // 3. ECO code (lower codes are generally more common) + continuations.sort((a, b) => { + // Prefer shallower depth + if (a.depth !== b.depth) return a.depth - b.depth; + + const aHasRoot = a.openings.some(o => o.isEcoRoot); + const bHasRoot = b.openings.some(o => o.isEcoRoot); + if (aHasRoot && !bHasRoot) return -1; + if (!aHasRoot && bHasRoot) return 1; + + // Compare by ECO code + const aEco = a.openings[0]?.eco || 'ZZZ'; + const bEco = b.openings[0]?.eco || 'ZZZ'; + return aEco.localeCompare(bEco); + }); + + // Add continuations until we reach maxResults + for (const { openings } of continuations) { + if (results.length >= maxResults) break; + + // Take the first opening from this continuation (usually the most general/important) + const opening = openings[0]; + if (opening) { + const ecoRoot = opening.eco.substring(0, 2); + // Avoid adding if we already have an opening from this ECO family + if (!seenEcoRoots.has(ecoRoot)) { + results.push(opening); + seenEcoRoots.add(ecoRoot); + } + } + } + } + + return results.slice(0, maxResults); } /**