Add mobile app support and opening training feature
This commit implements iOS/Android mobile app support using Capacitor and adds a comprehensive opening training feature with LLM-powered explanations. ## Mobile App Infrastructure - Add Capacitor configuration for iOS/Android builds - Create mobile build script that excludes API routes - Update Next.js config for conditional static export - Add layout components with generateStaticParams for static builds - Generate 500+ static pages for offline mobile use ## Chess Engine Abstraction - Create ChessEngine interface for pluggable implementations - Add LocalEngine (GPL - uses stockfish.js in browser) - Add RemoteEngine (proprietary - calls API server) - Factory pattern selects engine based on environment - Enables GPL compliance for web, proprietary for mobile ## Opening Training Feature - Interactive opening repertoire training - Move validation with engine-backed feedback - LLM explanations using Gemini API - Wikipedia integration for opening context - Opening family grouping (e4, d4, c4, etc.) - Session state management - Real-time move feedback with evaluation Components: - OpeningSelector: Browse and select openings by family - OpeningTrainer: Main training interface with chessboard - MoveFeedback: Display move quality and LLM explanations - WikipediaSummary: Show opening history and context - ErrorBoundary: Graceful error handling Services: - openingLoader: Load and filter opening database - engineService: Engine evaluation wrapper - moveValidator: Validate moves against repertoire - feedbackGenerator: Generate contextual feedback - wikipediaService: Fetch and cache Wikipedia data - sessionManager: Track training session state ## Wikipedia Integration - Automatic Wikipedia article fetching for openings - Client-side and server-side caching - Sanitized summaries with proper formatting - Link opening database to Wikipedia slugs - API endpoints for on-demand fetching ## Docker Improvements - Add entrypoint script for automatic data setup - Fetch Wikipedia data on first container startup - Generate opening move index automatically - Remove generated data from git (public/openings/*.json, public/wikipedia/*.json) - Add READMEs explaining data requirements - Update .gitignore for generated files ## Dual Licensing Strategy - Add LICENSING.md explaining dual licensing approach - GPL-3.0 for web builds (includes Stockfish) - Proprietary option for mobile builds (no GPL code) - Single codebase, multiple licensing models - Legal compliance documented ## API Endpoints - POST /api/v1/llm/opening-explanation - Get LLM move explanations - GET /api/v1/wikipedia/summary - Fetch Wikipedia summaries ## Type Updates - Add openingTraining types - Update Tutor component to use ChessEngine interface - Add Gemini error handling types 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -57,3 +57,8 @@ __pycache__/
|
||||
*.csv.zst
|
||||
*.pgn
|
||||
*.pgn.zst
|
||||
|
||||
# Generated opening and Wikipedia data (fetched at Docker startup)
|
||||
/public/openings/*.json
|
||||
/public/wikipedia/*.json
|
||||
/public/wikipedia/.initialized
|
||||
|
||||
+12
@@ -49,6 +49,15 @@ COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
|
||||
# Copy scripts and node_modules needed for Wikipedia/opening setup
|
||||
COPY --from=builder /app/scripts ./scripts
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
|
||||
# Copy entrypoint script and make executable
|
||||
COPY scripts/docker-entrypoint.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
# Set ownership to non-root user
|
||||
RUN chown -R nextjs:nodejs /app
|
||||
|
||||
@@ -66,5 +75,8 @@ ENV HOSTNAME="0.0.0.0"
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD node -e "require('http').get('http://localhost:3050/api/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})" || exit 1
|
||||
|
||||
# Use entrypoint for initialization
|
||||
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||
|
||||
# Start the application
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
# Licensing Strategy
|
||||
|
||||
## Overview
|
||||
|
||||
This project uses a **dual licensing approach** based on how the software is built and distributed:
|
||||
|
||||
### GPL-3.0 License (Web Version)
|
||||
|
||||
The **web version** of Chess Tutor includes Stockfish.js, which is licensed under GPL-3.0. Therefore:
|
||||
|
||||
- Source code: **GPL-3.0**
|
||||
- Web builds (using `LocalEngine`): **GPL-3.0**
|
||||
- Any distribution that includes Stockfish.js: **GPL-3.0**
|
||||
|
||||
Users can:
|
||||
- Use the web version for free
|
||||
- Bring their own API keys (Gemini)
|
||||
- Run locally with client-side Stockfish
|
||||
|
||||
### Proprietary License (Mobile Version)
|
||||
|
||||
The **mobile version** (iOS/Android) does NOT bundle Stockfish.js. Instead, it uses:
|
||||
|
||||
- `RemoteEngine` - Makes API calls to a hosted server for chess analysis
|
||||
- No GPL code is included in the mobile build
|
||||
- Static export with API-only architecture
|
||||
|
||||
Therefore, the mobile app can be distributed under a **proprietary license**:
|
||||
- Sold on App Store / Google Play
|
||||
- Uses hosted API service
|
||||
- No GPL restrictions apply
|
||||
|
||||
## How This Works
|
||||
|
||||
### Code Architecture
|
||||
|
||||
```typescript
|
||||
// Engine abstraction allows swapping implementations
|
||||
export interface ChessEngine {
|
||||
evaluate(fen: string, depth?: number): Promise<EngineEvaluation>;
|
||||
terminate(): void;
|
||||
}
|
||||
|
||||
// GPL-licensed (web only)
|
||||
class LocalEngine implements ChessEngine {
|
||||
// Uses stockfish.js in browser
|
||||
}
|
||||
|
||||
// No GPL dependencies (mobile)
|
||||
class RemoteEngine implements ChessEngine {
|
||||
// Calls API server
|
||||
}
|
||||
```
|
||||
|
||||
### Build Configuration
|
||||
|
||||
**Web Build** (`npm run build`):
|
||||
- Output: `output: 'standalone'` (Next.js server)
|
||||
- Includes: API routes with Stockfish
|
||||
- Engine: `LocalEngine` (GPL)
|
||||
- License: **GPL-3.0**
|
||||
|
||||
**Mobile Build** (`npm run build:mobile`):
|
||||
- Output: `output: 'export'` (static HTML/JS)
|
||||
- Excludes: API routes (temporarily removed during build)
|
||||
- Engine: `RemoteEngine` (proprietary)
|
||||
- License: **Proprietary**
|
||||
|
||||
## Legal Compliance
|
||||
|
||||
### GPL Compliance (Web)
|
||||
|
||||
The web version complies with GPL-3.0:
|
||||
- ✅ Source code is available
|
||||
- ✅ GPL license is included
|
||||
- ✅ Users can modify and redistribute
|
||||
- ✅ Users bring their own API keys (no lock-in)
|
||||
|
||||
### Mobile Compliance
|
||||
|
||||
The mobile version is NOT a derivative work of GPL code:
|
||||
- ✅ No Stockfish.js included in build
|
||||
- ✅ Uses network API calls (not linking)
|
||||
- ✅ Can be licensed separately
|
||||
- ✅ Users pay for hosted service
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
chess_tutor/
|
||||
├── LICENSE # GPL-3.0 (for source code and web)
|
||||
├── LICENSING.md # This file (dual licensing explanation)
|
||||
├── src/
|
||||
│ └── lib/
|
||||
│ ├── stockfish.ts # GPL-licensed (web only)
|
||||
│ └── engine/
|
||||
│ ├── LocalEngine.ts # GPL-licensed (web only)
|
||||
│ └── RemoteEngine.ts # Proprietary (mobile)
|
||||
├── public/ # Generated data (not in git)
|
||||
│ ├── openings/*.json # Fetched at Docker startup
|
||||
│ └── wikipedia/*.json # Fetched at Docker startup
|
||||
└── scripts/
|
||||
├── docker-entrypoint.sh # Fetches data on startup
|
||||
└── build-mobile.sh # Excludes GPL code
|
||||
```
|
||||
|
||||
## Developer Guidelines
|
||||
|
||||
### Contributing
|
||||
|
||||
All contributions to the source code repository are subject to **GPL-3.0**.
|
||||
|
||||
### Building for Web
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm start
|
||||
# GPL-3.0 applies
|
||||
```
|
||||
|
||||
### Building for Mobile
|
||||
|
||||
```bash
|
||||
npm run build:mobile
|
||||
# Proprietary license can apply (no GPL code included)
|
||||
```
|
||||
|
||||
### Deploying
|
||||
|
||||
**Web Deployment:**
|
||||
- Must comply with GPL-3.0
|
||||
- Must provide source code
|
||||
- Can be self-hosted for free
|
||||
|
||||
**Mobile App Store:**
|
||||
- Uses proprietary license
|
||||
- Connects to hosted API
|
||||
- Paid app model allowed
|
||||
|
||||
## Questions?
|
||||
|
||||
- **Web version**: GPL-3.0 applies because Stockfish.js is included
|
||||
- **Mobile version**: Proprietary license allowed because no GPL code is bundled
|
||||
- **Source code**: GPL-3.0 (contains GPL integration code)
|
||||
|
||||
This approach allows:
|
||||
- ✅ Free web version (GPL-compliant)
|
||||
- ✅ Paid mobile app (proprietary)
|
||||
- ✅ Single codebase
|
||||
- ✅ Legal compliance
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { CapacitorConfig } from '@capacitor/cli';
|
||||
|
||||
const config: CapacitorConfig = {
|
||||
appId: 'com.kaproblem.chesstutor',
|
||||
appName: 'Chess Tutor',
|
||||
webDir: 'out', // Next.js static export output directory
|
||||
|
||||
server: {
|
||||
// Use HTTPS scheme for Android to avoid cleartext issues
|
||||
androidScheme: 'https',
|
||||
},
|
||||
|
||||
// iOS configuration
|
||||
ios: {
|
||||
contentInset: 'automatic',
|
||||
},
|
||||
|
||||
// Android configuration
|
||||
android: {
|
||||
buildOptions: {
|
||||
keystorePath: undefined,
|
||||
keystoreAlias: undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
+17
-1
@@ -1,7 +1,23 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
// Check if building for mobile (static export) or web (server)
|
||||
const isMobileBuild = process.env.BUILD_TARGET === 'mobile';
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
// Mobile: static export (no API routes, no server)
|
||||
// Web: standalone (includes API routes)
|
||||
output: isMobileBuild ? 'export' : 'standalone',
|
||||
|
||||
// Disable image optimization for static export
|
||||
images: {
|
||||
unoptimized: isMobileBuild,
|
||||
},
|
||||
|
||||
// Environment variables
|
||||
env: {
|
||||
NEXT_PUBLIC_USE_REMOTE_ENGINE: process.env.NEXT_PUBLIC_USE_REMOTE_ENGINE || (isMobileBuild ? 'true' : 'false'),
|
||||
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || '',
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
Generated
+1466
-60
File diff suppressed because it is too large
Load Diff
+18
-2
@@ -12,14 +12,27 @@
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:headed": "playwright test --headed",
|
||||
"test:all": "npm test && npm run test:e2e"
|
||||
"test:all": "npm test && npm run test:e2e",
|
||||
"build:opening-index": "node scripts/buildOpeningIndex.js",
|
||||
"cache:wikipedia": "tsx scripts/fetch-wikipedia-openings.ts",
|
||||
"update:wikipedia-slugs": "tsx scripts/add-wikipedia-to-openings.ts",
|
||||
"build:mobile": "./scripts/build-mobile.sh",
|
||||
"mobile:build": "npm run build:mobile && npx cap sync",
|
||||
"mobile:ios": "npm run mobile:build && npx cap open ios",
|
||||
"mobile:android": "npm run mobile:build && npx cap open android",
|
||||
"cap:sync": "npx cap sync",
|
||||
"cap:copy": "npx cap copy"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor/android": "^7.4.4",
|
||||
"@capacitor/cli": "^7.4.4",
|
||||
"@capacitor/core": "^7.4.4",
|
||||
"@capacitor/ios": "^7.4.4",
|
||||
"@google/generative-ai": "^0.24.1",
|
||||
"chess.js": "^1.4.0",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.554.0",
|
||||
"next": "16.0.3",
|
||||
"next": "^16.0.7",
|
||||
"react": "19.2.0",
|
||||
"react-chessboard": "^5.8.4",
|
||||
"react-dom": "19.2.0",
|
||||
@@ -37,6 +50,8 @@
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"baseline-browser-mapping": "^2.9.4",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.0.3",
|
||||
"jest": "^30.2.0",
|
||||
@@ -44,6 +59,7 @@
|
||||
"tailwindcss": "^4",
|
||||
"ts-jest": "^29.4.5",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# Opening Database
|
||||
|
||||
This directory contains the ECO (Encyclopedia of Chess Openings) database files.
|
||||
|
||||
## Required Files
|
||||
|
||||
The following files are required for opening training:
|
||||
|
||||
- `ecoA.json` - ECO codes A00-A99
|
||||
- `ecoB.json` - ECO codes B00-B99
|
||||
- `ecoC.json` - ECO codes C00-C99
|
||||
- `ecoD.json` - ECO codes D00-D99
|
||||
- `ecoE.json` - ECO codes E00-E99
|
||||
- `moveIndex.json` - Generated move sequence index
|
||||
|
||||
## File Format
|
||||
|
||||
Each ECO file (ecoA-E.json) should be a JSON object mapping FEN positions to opening metadata:
|
||||
|
||||
```json
|
||||
{
|
||||
"fen_position": {
|
||||
"eco": "A00",
|
||||
"name": "Opening Name",
|
||||
"moves": "e4 e5 Nf3 Nc6",
|
||||
"wikipediaSlug": "opening-name" // optional
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
### Option 1: Docker (Automatic)
|
||||
|
||||
When running via Docker, these files should be provided as a volume mount:
|
||||
|
||||
```bash
|
||||
docker run -v ./openings:/app/public/openings ghcr.io/stefan-kp/chess-tutor
|
||||
```
|
||||
|
||||
### Option 2: Local Development
|
||||
|
||||
1. Obtain ECO database files (ecoA-E.json)
|
||||
2. Place them in this directory
|
||||
3. Generate the move index:
|
||||
|
||||
```bash
|
||||
npm run build:opening-index
|
||||
```
|
||||
|
||||
This will create `moveIndex.json` from the ECO files.
|
||||
|
||||
### Option 3: Generate from PGN
|
||||
|
||||
If you have a PGN database, you can extract ECO codes using chess tools like:
|
||||
- `pgn-extract`
|
||||
- Custom scripts
|
||||
|
||||
## Notes
|
||||
|
||||
- These files are **not included in git** (too large, ~4MB total)
|
||||
- Users must provide their own opening database
|
||||
- Wikipedia integration is optional (see `public/wikipedia/README.md`)
|
||||
- The move index is automatically generated during Docker startup
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
# Wikipedia Opening Cache
|
||||
|
||||
This directory contains cached Wikipedia article summaries for chess openings.
|
||||
|
||||
## What is this?
|
||||
|
||||
The Wikipedia cache provides educational context about chess openings:
|
||||
- Article summaries
|
||||
- Opening history
|
||||
- Strategic ideas
|
||||
- Notable games
|
||||
|
||||
## Automatic Setup (Docker)
|
||||
|
||||
When running via Docker, Wikipedia data is **automatically fetched** on first startup:
|
||||
|
||||
```bash
|
||||
docker-compose up
|
||||
# Will fetch Wikipedia data on first run
|
||||
# Cached for subsequent runs
|
||||
```
|
||||
|
||||
The Docker entrypoint script (`scripts/docker-entrypoint.sh`) handles:
|
||||
1. Fetching Wikipedia articles for all openings
|
||||
2. Sanitizing/formatting the data
|
||||
3. Linking Wikipedia slugs to opening database
|
||||
|
||||
## Manual Setup (Development)
|
||||
|
||||
```bash
|
||||
# Fetch Wikipedia articles
|
||||
npm run cache:wikipedia
|
||||
|
||||
# Update opening database with Wikipedia slugs
|
||||
npm run update:wikipedia-slugs
|
||||
```
|
||||
|
||||
## Cache Invalidation
|
||||
|
||||
To refresh Wikipedia data:
|
||||
|
||||
```bash
|
||||
rm public/wikipedia/*.json
|
||||
docker-compose restart
|
||||
# Or: npm run cache:wikipedia
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Files are **not committed to git** (generated at runtime)
|
||||
- Wikipedia API has rate limits (be patient)
|
||||
- Wikipedia content is optional (app works without it)
|
||||
- `.initialized` marker prevents re-fetching on every startup
|
||||
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env tsx
|
||||
|
||||
/**
|
||||
* Add Wikipedia slugs to opening database
|
||||
*
|
||||
* This script updates the opening JSON files to include wikipediaSlug field
|
||||
* based on the cached Wikipedia articles we've already downloaded.
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
interface OpeningMetadata {
|
||||
name: string;
|
||||
eco: string;
|
||||
moves: string;
|
||||
isEcoRoot?: boolean;
|
||||
wikipediaSlug?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract family name from opening name
|
||||
*/
|
||||
function extractFamilyName(openingName: string): string {
|
||||
const separators = [':', ',', '–', '—', ' - '];
|
||||
for (const sep of separators) {
|
||||
if (openingName.includes(sep)) {
|
||||
return openingName.split(sep)[0].trim();
|
||||
}
|
||||
}
|
||||
return openingName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert family name to slug
|
||||
*/
|
||||
function familyNameToSlug(familyName: string): string {
|
||||
return familyName.toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load Wikipedia cache index to see what we have
|
||||
*/
|
||||
function getAvailableWikipediaSlugs(): Set<string> {
|
||||
const wikiDir = path.join(__dirname, '..', 'public', 'wikipedia');
|
||||
const slugs = new Set<string>();
|
||||
|
||||
if (!fs.existsSync(wikiDir)) {
|
||||
return slugs;
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(wikiDir);
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.json') && file !== 'index.json') {
|
||||
const slug = file.replace('.json', '');
|
||||
slugs.add(slug);
|
||||
}
|
||||
}
|
||||
|
||||
return slugs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update opening database files with Wikipedia slugs
|
||||
*/
|
||||
function updateOpeningDatabases() {
|
||||
const availableSlugs = getAvailableWikipediaSlugs();
|
||||
console.log(`\n📚 Found ${availableSlugs.size} Wikipedia cache files\n`);
|
||||
|
||||
const ecoFiles = ['ecoA', 'ecoB', 'ecoC', 'ecoD', 'ecoE'];
|
||||
let totalUpdated = 0;
|
||||
let totalSkipped = 0;
|
||||
|
||||
for (const ecoFile of ecoFiles) {
|
||||
const filePath = path.join(__dirname, '..', 'public', 'openings', `${ecoFile}.json`);
|
||||
|
||||
console.log(`\n📖 Processing ${ecoFile}.json...`);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.log(` ⚠️ File not found, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const data: Record<string, OpeningMetadata> = JSON.parse(
|
||||
fs.readFileSync(filePath, 'utf-8')
|
||||
);
|
||||
|
||||
let updatedCount = 0;
|
||||
let skippedCount = 0;
|
||||
|
||||
// Update each opening
|
||||
for (const [fen, opening] of Object.entries(data)) {
|
||||
const familyName = extractFamilyName(opening.name);
|
||||
const slug = familyNameToSlug(familyName);
|
||||
|
||||
if (availableSlugs.has(slug)) {
|
||||
opening.wikipediaSlug = slug;
|
||||
updatedCount++;
|
||||
} else {
|
||||
skippedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Write updated file
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
||||
|
||||
console.log(` ✓ Updated ${updatedCount} openings`);
|
||||
console.log(` ⚠️ Skipped ${skippedCount} (no Wikipedia cache)`);
|
||||
|
||||
totalUpdated += updatedCount;
|
||||
totalSkipped += skippedCount;
|
||||
}
|
||||
|
||||
console.log('\n' + '='.repeat(50));
|
||||
console.log('✨ Wikipedia Slug Addition Complete!\n');
|
||||
console.log(`✓ Total updated: ${totalUpdated}`);
|
||||
console.log(`⚠️ Total skipped: ${totalSkipped}`);
|
||||
console.log('='.repeat(50) + '\n');
|
||||
}
|
||||
|
||||
// Run the script
|
||||
console.log('🔗 Adding Wikipedia Slugs to Opening Database\n');
|
||||
updateOpeningDatabases();
|
||||
Executable
+30
@@ -0,0 +1,30 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Mobile build script
|
||||
# Temporarily moves API folder outside src/, builds static export, then restores it
|
||||
|
||||
set -e
|
||||
|
||||
echo "🔧 Preparing mobile build..."
|
||||
|
||||
# Clean previous build (suppress errors for non-empty directories)
|
||||
echo "🧹 Cleaning previous build..."
|
||||
rm -rf .next out 2>/dev/null || true
|
||||
|
||||
# Backup API folder to temp location OUTSIDE src/
|
||||
if [ -d "src/app/api" ]; then
|
||||
echo "📦 Temporarily moving API routes outside src/..."
|
||||
mv src/app/api .api_temp_mobile_build
|
||||
fi
|
||||
|
||||
# Build with mobile configuration
|
||||
echo "🏗️ Building static export for mobile..."
|
||||
BUILD_TARGET=mobile NEXT_PUBLIC_USE_REMOTE_ENGINE=true next build
|
||||
|
||||
# Restore API folder
|
||||
if [ -d ".api_temp_mobile_build" ]; then
|
||||
echo "📦 Restoring API routes..."
|
||||
mv .api_temp_mobile_build src/app/api
|
||||
fi
|
||||
|
||||
echo "✅ Mobile build complete! Output in ./out"
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
echo "🔧 Chess Tutor Docker Entrypoint"
|
||||
|
||||
# Create directories if they don't exist
|
||||
mkdir -p public/openings
|
||||
mkdir -p public/wikipedia
|
||||
|
||||
# Check if Wikipedia cache needs to be populated
|
||||
if [ ! -f "public/wikipedia/.initialized" ] || [ -z "$(ls -A public/wikipedia/*.json 2>/dev/null)" ]; then
|
||||
echo "📚 Fetching Wikipedia opening data..."
|
||||
npm run cache:wikipedia || echo "⚠️ Warning: Wikipedia fetch failed, continuing..."
|
||||
|
||||
echo "🔗 Updating Wikipedia slugs in opening database..."
|
||||
npm run update:wikipedia-slugs || echo "⚠️ Warning: Wikipedia slug update failed, continuing..."
|
||||
|
||||
# Mark as initialized
|
||||
touch public/wikipedia/.initialized
|
||||
echo "✅ Wikipedia data initialized"
|
||||
else
|
||||
echo "✅ Wikipedia cache already populated, skipping..."
|
||||
fi
|
||||
|
||||
# Check if opening database exists
|
||||
if [ ! -f "public/openings/ecoA.json" ]; then
|
||||
echo "⚠️ Warning: Opening database not found in public/openings/"
|
||||
echo " Please ensure opening database files (ecoA-E.json, moveIndex.json) are available"
|
||||
echo " The application will continue but opening training may not work properly"
|
||||
fi
|
||||
|
||||
echo "🚀 Starting Chess Tutor application..."
|
||||
|
||||
# Execute the main command (node server.js)
|
||||
exec "$@"
|
||||
@@ -0,0 +1,447 @@
|
||||
#!/usr/bin/env tsx
|
||||
|
||||
/**
|
||||
* Wikipedia Opening Cache Builder
|
||||
*
|
||||
* Fetches full Wikipedia articles for chess opening families and caches them locally.
|
||||
* This eliminates runtime API calls and makes Wikipedia content available offline.
|
||||
*
|
||||
* Wikipedia content is licensed under CC BY-SA 3.0
|
||||
* https://creativecommons.org/licenses/by-sa/3.0/
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
interface WikipediaArticle {
|
||||
openingFamily: string;
|
||||
title: string;
|
||||
url: string;
|
||||
sections: {
|
||||
title: string;
|
||||
text: string;
|
||||
}[];
|
||||
lastModified: string;
|
||||
license: string;
|
||||
licenseUrl: string;
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract opening family names from the opening database
|
||||
*/
|
||||
function extractOpeningFamilies(): string[] {
|
||||
const ecoFiles = ['ecoA', 'ecoB', 'ecoC', 'ecoD', 'ecoE'];
|
||||
const families = new Set<string>();
|
||||
|
||||
for (const ecoFile of ecoFiles) {
|
||||
const filePath = path.join(__dirname, '..', 'public', 'openings', `${ecoFile}.json`);
|
||||
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
|
||||
// Extract family names from each opening
|
||||
for (const opening of Object.values(data) as any[]) {
|
||||
if (opening.name && opening.isEcoRoot === true) {
|
||||
const familyName = extractFamilyName(opening.name);
|
||||
if (familyName) {
|
||||
families.add(familyName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(families).sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract family name from opening name
|
||||
*/
|
||||
function extractFamilyName(openingName: string): string {
|
||||
const separators = [':', ',', '–', '—', ' - '];
|
||||
for (const sep of separators) {
|
||||
if (openingName.includes(sep)) {
|
||||
return openingName.split(sep)[0].trim();
|
||||
}
|
||||
}
|
||||
return openingName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual overrides for problematic opening names
|
||||
* Maps opening family name -> exact Wikipedia article title
|
||||
*/
|
||||
const WIKIPEDIA_OVERRIDES: Record<string, string> = {
|
||||
'French Defense': 'French Defence',
|
||||
'French': 'French Defence',
|
||||
'English Opening': 'English Opening',
|
||||
'English': 'English Opening',
|
||||
'Dutch Defense': 'Dutch Defence',
|
||||
'Dutch': 'Dutch Defence',
|
||||
'Spanish Game': 'Ruy Lopez',
|
||||
'Italian Game': 'Italian Game',
|
||||
'Scandinavian Defense': 'Scandinavian Defense',
|
||||
'Pirc Defense': 'Pirc Defence',
|
||||
'Modern Defense': 'Modern Defense (chess)',
|
||||
};
|
||||
|
||||
/**
|
||||
* Search Wikipedia for the best matching article
|
||||
*/
|
||||
async function searchWikipedia(openingFamily: string): Promise<string | null> {
|
||||
// Check manual overrides first
|
||||
if (WIKIPEDIA_OVERRIDES[openingFamily]) {
|
||||
console.log(` Using manual override: "${WIKIPEDIA_OVERRIDES[openingFamily]}"`);
|
||||
return WIKIPEDIA_OVERRIDES[openingFamily];
|
||||
}
|
||||
|
||||
// Try two search strategies:
|
||||
// 1. Search with "chess opening" appended (more specific)
|
||||
// 2. Search with original name (fallback)
|
||||
const searchQueries = [
|
||||
`${openingFamily} chess opening`,
|
||||
openingFamily,
|
||||
];
|
||||
|
||||
for (const query of searchQueries) {
|
||||
const searchUrl = new URL('https://en.wikipedia.org/w/api.php');
|
||||
searchUrl.searchParams.set('action', 'opensearch');
|
||||
searchUrl.searchParams.set('search', query);
|
||||
searchUrl.searchParams.set('limit', '10'); // Increased from 5 to get more options
|
||||
searchUrl.searchParams.set('namespace', '0');
|
||||
searchUrl.searchParams.set('format', 'json');
|
||||
|
||||
console.log(` Searching Wikipedia for: "${query}"`);
|
||||
|
||||
const response = await fetch(searchUrl.toString(), {
|
||||
headers: {
|
||||
'User-Agent': 'ChessTutorApp/1.0 (Educational chess training app; cache builder)',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(` ❌ Search failed: ${response.status}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const titles = data[1] as string[];
|
||||
const descriptions = data[2] as string[];
|
||||
|
||||
if (!titles || titles.length === 0) {
|
||||
console.log(` ⚠️ No results found for this query`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Score each result based on chess relevance
|
||||
const scoredResults = titles.map((title, i) => {
|
||||
const description = descriptions[i] || '';
|
||||
const lowerTitle = title.toLowerCase();
|
||||
const lowerDesc = description.toLowerCase();
|
||||
|
||||
let score = 0;
|
||||
|
||||
// Skip disambiguation pages (they're not what we want)
|
||||
if (lowerTitle.includes('(disambiguation)') || lowerDesc.includes('may refer to')) {
|
||||
return { title, description, score: -1000 };
|
||||
}
|
||||
|
||||
// Strong chess indicators
|
||||
if (lowerDesc.includes('chess opening')) score += 100;
|
||||
if (lowerTitle.includes('chess')) score += 50;
|
||||
if (lowerDesc.includes('chess')) score += 30;
|
||||
if (lowerDesc.includes('opening')) score += 20;
|
||||
|
||||
// Additional chess terms
|
||||
if (lowerDesc.includes('variation') || lowerDesc.includes('defense') || lowerDesc.includes('defence')) score += 10;
|
||||
if (lowerDesc.includes('game') && lowerDesc.includes('chess')) score += 15;
|
||||
|
||||
// Prefer exact or close matches to opening name
|
||||
if (lowerTitle.includes(openingFamily.toLowerCase())) score += 40;
|
||||
|
||||
// Penalize generic terms that suggest it's not the chess opening
|
||||
if (lowerDesc.includes('language') || lowerDesc.includes('people') ||
|
||||
lowerDesc.includes('cuisine') || lowerDesc.includes('culture')) {
|
||||
score -= 50;
|
||||
}
|
||||
|
||||
return { title, description, score };
|
||||
});
|
||||
|
||||
// Sort by score (highest first)
|
||||
scoredResults.sort((a, b) => b.score - a.score);
|
||||
|
||||
// Pick the best match if it has a positive score
|
||||
const bestMatch = scoredResults[0];
|
||||
if (bestMatch && bestMatch.score > 0) {
|
||||
console.log(` ✓ Found: "${bestMatch.title}" (score: ${bestMatch.score})`);
|
||||
return bestMatch.title;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(` ⚠️ No suitable chess article found`);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch full Wikipedia article content
|
||||
*/
|
||||
async function fetchWikipediaArticle(
|
||||
openingFamily: string,
|
||||
articleTitle: string
|
||||
): Promise<WikipediaArticle | null> {
|
||||
// Use MediaWiki API to get parsed content with sections
|
||||
const apiUrl = new URL('https://en.wikipedia.org/w/api.php');
|
||||
apiUrl.searchParams.set('action', 'parse');
|
||||
apiUrl.searchParams.set('page', articleTitle);
|
||||
apiUrl.searchParams.set('prop', 'sections|text|displaytitle|revid');
|
||||
apiUrl.searchParams.set('format', 'json');
|
||||
apiUrl.searchParams.set('formatversion', '2');
|
||||
|
||||
console.log(` Fetching full article...`);
|
||||
|
||||
const response = await fetch(apiUrl.toString(), {
|
||||
headers: {
|
||||
'User-Agent': 'ChessTutorApp/1.0 (Educational chess training app; cache builder)',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(` ❌ Fetch failed: ${response.status}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
console.error(` ❌ API error:`, data.error);
|
||||
return null;
|
||||
}
|
||||
|
||||
const parseData = data.parse;
|
||||
const fullHtml = parseData.text;
|
||||
const sectionsData = parseData.sections || [];
|
||||
|
||||
// Extract sections from the HTML
|
||||
const sections = extractSections(fullHtml, sectionsData);
|
||||
|
||||
// Get last modified date
|
||||
const lastModified = new Date().toISOString();
|
||||
|
||||
const article: WikipediaArticle = {
|
||||
openingFamily,
|
||||
title: parseData.displaytitle || articleTitle,
|
||||
url: `https://en.wikipedia.org/wiki/${encodeURIComponent(articleTitle)}`,
|
||||
sections,
|
||||
lastModified,
|
||||
license: 'CC BY-SA 3.0',
|
||||
licenseUrl: 'https://creativecommons.org/licenses/by-sa/3.0/',
|
||||
fetchedAt: Date.now(),
|
||||
};
|
||||
|
||||
// Verify this is actually a chess article
|
||||
const allText = sections.map(s => s.text).join(' ').toLowerCase();
|
||||
const isChessArticle =
|
||||
allText.includes('chess') ||
|
||||
allText.includes('opening') ||
|
||||
allText.includes('variation') ||
|
||||
allText.includes('defense') ||
|
||||
allText.includes('defence') ||
|
||||
allText.includes('game') ||
|
||||
allText.includes('move');
|
||||
|
||||
if (!isChessArticle) {
|
||||
console.log(` ⚠️ Article doesn't appear to be about chess (verification failed)`);
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(` ✓ Fetched ${sections.length} sections (verified as chess content)`);
|
||||
return article;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract clean text sections from Wikipedia HTML
|
||||
*/
|
||||
function extractSections(
|
||||
html: string,
|
||||
sectionsData: any[]
|
||||
): { title: string; text: string }[] {
|
||||
// Parse HTML and extract meaningful sections
|
||||
// For now, we'll get the intro and first few sections
|
||||
const sections: { title: string; text: string }[] = [];
|
||||
|
||||
// Extract intro (text before first heading)
|
||||
const introMatch = html.match(/<p>([\s\S]*?)(?=<h2|$)/);
|
||||
if (introMatch) {
|
||||
const introText = stripHtml(introMatch[1]);
|
||||
if (introText.trim().length > 50) {
|
||||
sections.push({
|
||||
title: 'Introduction',
|
||||
text: introText,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Extract sections (we'll take first 5 for brevity)
|
||||
const relevantSections = sectionsData
|
||||
.filter((s: any) => s.toclevel === 1) // Top-level sections only
|
||||
.slice(0, 5);
|
||||
|
||||
for (const section of relevantSections) {
|
||||
const sectionTitle = section.line;
|
||||
|
||||
// Skip non-relevant sections
|
||||
if (
|
||||
sectionTitle.toLowerCase().includes('references') ||
|
||||
sectionTitle.toLowerCase().includes('external links') ||
|
||||
sectionTitle.toLowerCase().includes('see also') ||
|
||||
sectionTitle.toLowerCase().includes('notes')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract section content
|
||||
const sectionRegex = new RegExp(
|
||||
`<h2[^>]*>.*?${escapeRegex(sectionTitle)}.*?</h2>([\s\S]*?)(?=<h2|$)`,
|
||||
'i'
|
||||
);
|
||||
const sectionMatch = html.match(sectionRegex);
|
||||
|
||||
if (sectionMatch) {
|
||||
const sectionText = stripHtml(sectionMatch[1]);
|
||||
if (sectionText.trim().length > 50) {
|
||||
sections.push({
|
||||
title: sectionTitle,
|
||||
text: sectionText,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip HTML tags and clean text
|
||||
*/
|
||||
function stripHtml(html: string): string {
|
||||
return html
|
||||
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '') // Remove style tags
|
||||
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '') // Remove script tags
|
||||
.replace(/<sup[^>]*>[\s\S]*?<\/sup>/gi, '') // Remove citation superscripts
|
||||
.replace(/<\/?[^>]+(>|$)/g, '') // Remove all other tags
|
||||
.replace(/\[[0-9]+\]/g, '') // Remove citation numbers [1], [2], etc.
|
||||
.replace(/ /g, ' ') // Replace
|
||||
.replace(/&/g, '&') // Replace &
|
||||
.replace(/</g, '<') // Replace <
|
||||
.replace(/>/g, '>') // Replace >
|
||||
.replace(/\n\s*\n/g, '\n\n') // Clean up multiple newlines
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape special regex characters
|
||||
*/
|
||||
function escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Main execution
|
||||
*/
|
||||
async function main() {
|
||||
console.log('🌐 Wikipedia Opening Cache Builder\n');
|
||||
console.log('📚 Extracting opening families from database...');
|
||||
|
||||
const families = extractOpeningFamilies();
|
||||
console.log(`✓ Found ${families.length} unique opening families\n`);
|
||||
|
||||
const outputDir = path.join(__dirname, '..', 'public', 'wikipedia');
|
||||
|
||||
// Create output directory if it doesn't exist
|
||||
if (!fs.existsSync(outputDir)) {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
console.log(`✓ Created directory: ${outputDir}\n`);
|
||||
}
|
||||
|
||||
const results = {
|
||||
successful: 0,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
};
|
||||
|
||||
// Process each family
|
||||
for (const family of families) {
|
||||
console.log(`\n📖 Processing: ${family}`);
|
||||
|
||||
try {
|
||||
// Search for the article
|
||||
const articleTitle = await searchWikipedia(family);
|
||||
|
||||
if (!articleTitle) {
|
||||
console.log(` ⚠️ Skipping (no Wikipedia article found)`);
|
||||
results.skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fetch full article
|
||||
const article = await fetchWikipediaArticle(family, articleTitle);
|
||||
|
||||
if (!article) {
|
||||
console.log(` ❌ Failed to fetch article`);
|
||||
results.failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Save to file
|
||||
const slug = family.toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
||||
const filename = `${slug}.json`;
|
||||
const filepath = path.join(outputDir, filename);
|
||||
|
||||
fs.writeFileSync(filepath, JSON.stringify(article, null, 2));
|
||||
console.log(` ✓ Saved to: ${filename}`);
|
||||
results.successful++;
|
||||
|
||||
// Rate limiting - be nice to Wikipedia
|
||||
await sleep(1000);
|
||||
} catch (error) {
|
||||
console.error(` ❌ Error:`, error);
|
||||
results.failed++;
|
||||
}
|
||||
}
|
||||
|
||||
// Create index file
|
||||
console.log('\n📝 Creating index file...');
|
||||
const indexPath = path.join(outputDir, 'index.json');
|
||||
const indexData = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
totalFamilies: families.length,
|
||||
successful: results.successful,
|
||||
failed: results.failed,
|
||||
skipped: results.skipped,
|
||||
license: 'Wikipedia content licensed under CC BY-SA 3.0',
|
||||
licenseUrl: 'https://creativecommons.org/licenses/by-sa/3.0/',
|
||||
};
|
||||
fs.writeFileSync(indexPath, JSON.stringify(indexData, null, 2));
|
||||
|
||||
// Summary
|
||||
console.log('\n' + '='.repeat(50));
|
||||
console.log('✨ Wikipedia Cache Build Complete!\n');
|
||||
console.log(`✓ Successful: ${results.successful}`);
|
||||
console.log(`⚠️ Skipped: ${results.skipped}`);
|
||||
console.log(`❌ Failed: ${results.failed}`);
|
||||
console.log(`📁 Output: ${outputDir}`);
|
||||
console.log('='.repeat(50) + '\n');
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// Run the script
|
||||
main().catch((error) => {
|
||||
console.error('Fatal error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { GoogleGenerativeAI } from '@google/generative-ai';
|
||||
import {
|
||||
OPENING_TUTOR_SYSTEM_PROMPT,
|
||||
OPENING_TUTOR_TEMPERATURE,
|
||||
OPENING_TUTOR_MAX_TOKENS,
|
||||
generateFallbackExplanation,
|
||||
} from '@/lib/server/openingTutorPrompt';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const maxDuration = 10; // 10 second timeout
|
||||
|
||||
/**
|
||||
* Opening Explanation API Endpoint
|
||||
* Generates educational explanations for chess moves using LLM
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const {
|
||||
prompt,
|
||||
moveSan,
|
||||
category,
|
||||
theoreticalMoves,
|
||||
evalChange,
|
||||
bestMove,
|
||||
} = body;
|
||||
|
||||
if (!prompt) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing prompt parameter' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check for API key
|
||||
const apiKey = process.env.GEMINI_API_KEY;
|
||||
if (!apiKey) {
|
||||
console.warn('GEMINI_API_KEY not configured, using fallback explanation');
|
||||
const fallback = generateFallbackExplanation(
|
||||
category,
|
||||
moveSan,
|
||||
theoreticalMoves,
|
||||
evalChange,
|
||||
bestMove
|
||||
);
|
||||
return NextResponse.json({
|
||||
explanation: fallback,
|
||||
usedFallback: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize Gemini API
|
||||
const genAI = new GoogleGenerativeAI(apiKey);
|
||||
const model = genAI.getGenerativeModel({
|
||||
model: 'gemini-1.5-flash',
|
||||
generationConfig: {
|
||||
temperature: OPENING_TUTOR_TEMPERATURE,
|
||||
maxOutputTokens: OPENING_TUTOR_MAX_TOKENS,
|
||||
},
|
||||
systemInstruction: OPENING_TUTOR_SYSTEM_PROMPT,
|
||||
});
|
||||
|
||||
// Generate explanation
|
||||
const result = await model.generateContent(prompt);
|
||||
const response = result.response;
|
||||
const explanation = response.text();
|
||||
|
||||
if (!explanation || explanation.trim().length === 0) {
|
||||
// Empty response - use fallback
|
||||
const fallback = generateFallbackExplanation(
|
||||
category,
|
||||
moveSan,
|
||||
theoreticalMoves,
|
||||
evalChange,
|
||||
bestMove
|
||||
);
|
||||
return NextResponse.json({
|
||||
explanation: fallback,
|
||||
usedFallback: true,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
explanation: explanation.trim(),
|
||||
usedFallback: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('LLM explanation error:', error);
|
||||
|
||||
// Try to extract fallback params from request
|
||||
let fallbackExplanation = 'Unable to generate explanation at this time.';
|
||||
try {
|
||||
const body = await request.json();
|
||||
if (body.moveSan && body.category) {
|
||||
fallbackExplanation = generateFallbackExplanation(
|
||||
body.category,
|
||||
body.moveSan,
|
||||
body.theoreticalMoves || [],
|
||||
body.evalChange,
|
||||
body.bestMove
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Ignore fallback generation errors
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
explanation: fallbackExplanation,
|
||||
usedFallback: true,
|
||||
error: 'LLM request failed',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* Wikipedia Summary API Endpoint
|
||||
* Fetches Wikipedia article summaries for chess openings
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const openingName = searchParams.get('opening');
|
||||
|
||||
if (!openingName) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing opening parameter' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[Wikipedia API] Searching for:', openingName);
|
||||
|
||||
// Step 1: Use Wikipedia Search API to find the best matching article
|
||||
// This handles partial matches, redirects, and disambiguations
|
||||
const searchUrl = new URL('https://en.wikipedia.org/w/api.php');
|
||||
searchUrl.searchParams.set('action', 'opensearch');
|
||||
searchUrl.searchParams.set('search', openingName);
|
||||
searchUrl.searchParams.set('limit', '5'); // Get top 5 results
|
||||
searchUrl.searchParams.set('namespace', '0'); // Main articles only
|
||||
searchUrl.searchParams.set('format', 'json');
|
||||
|
||||
const searchResponse = await fetch(searchUrl.toString(), {
|
||||
headers: {
|
||||
'User-Agent': 'ChessTutorApp/1.0 (Educational chess training app)',
|
||||
},
|
||||
});
|
||||
|
||||
if (!searchResponse.ok) {
|
||||
console.error('[Wikipedia API] Search failed:', searchResponse.status);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Wikipedia search failed',
|
||||
fallback: 'No background information available for this opening.',
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const searchData = await searchResponse.json();
|
||||
// OpenSearch returns: [query, [titles], [descriptions], [urls]]
|
||||
const titles = searchData[1] as string[];
|
||||
const descriptions = searchData[2] as string[];
|
||||
|
||||
if (!titles || titles.length === 0) {
|
||||
console.log('[Wikipedia API] No results found for:', openingName);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Wikipedia article not found',
|
||||
fallback: 'No background information available for this opening.',
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Find best match (prioritize chess-related articles)
|
||||
let bestMatch = titles[0]; // Default to first result
|
||||
for (let i = 0; i < titles.length; i++) {
|
||||
const title = titles[i];
|
||||
const description = descriptions[i] || '';
|
||||
|
||||
// Prioritize results with chess-related keywords
|
||||
if (
|
||||
description.toLowerCase().includes('chess') ||
|
||||
description.toLowerCase().includes('opening') ||
|
||||
title.toLowerCase().includes('chess')
|
||||
) {
|
||||
bestMatch = title;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[Wikipedia API] Best match:', bestMatch, 'from', titles.length, 'results');
|
||||
|
||||
// Step 2: Fetch summary for the best matching article
|
||||
const summaryUrl = `https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(
|
||||
bestMatch
|
||||
)}`;
|
||||
|
||||
const summaryResponse = await fetch(summaryUrl, {
|
||||
headers: {
|
||||
'User-Agent': 'ChessTutorApp/1.0 (Educational chess training app)',
|
||||
},
|
||||
});
|
||||
|
||||
if (!summaryResponse.ok) {
|
||||
console.error('[Wikipedia API] Summary fetch failed for:', bestMatch);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Wikipedia article not found',
|
||||
fallback: 'No background information available for this opening.',
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await summaryResponse.json();
|
||||
|
||||
// Format the response
|
||||
const summary = {
|
||||
openingName,
|
||||
title: data.title,
|
||||
extract: data.extract,
|
||||
url:
|
||||
data.content_urls?.desktop?.page ||
|
||||
`https://en.wikipedia.org/wiki/${encodeURIComponent(bestMatch)}`,
|
||||
fetchedAt: Date.now(),
|
||||
expiresAt: Date.now() + 30 * 24 * 60 * 60 * 1000, // 30 days
|
||||
};
|
||||
|
||||
console.log('[Wikipedia API] Successfully fetched:', data.title);
|
||||
return NextResponse.json(summary);
|
||||
} catch (error) {
|
||||
console.error('[Wikipedia API] Error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch Wikipedia summary' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Server component layout for static export
|
||||
// This allows generateStaticParams while the page remains a client component
|
||||
|
||||
export async function generateStaticParams() {
|
||||
// Generate params for root ECO codes (A00-E99)
|
||||
// This creates 500 static pages for the mobile build
|
||||
const ecoRoots = [];
|
||||
for (const letter of ['A', 'B', 'C', 'D', 'E']) {
|
||||
for (let num = 0; num <= 99; num++) {
|
||||
const eco = `${letter}${String(num).padStart(2, '0')}`;
|
||||
ecoRoots.push({ openingId: eco });
|
||||
}
|
||||
}
|
||||
return ecoRoots;
|
||||
}
|
||||
|
||||
export default function OpeningIdLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import Header from '@/components/Header';
|
||||
import { useTranslation } from '@/lib/i18n/useTranslation';
|
||||
import { SupportedLanguage } from '@/lib/i18n/translations';
|
||||
import { OpeningMetadata } from '@/lib/openings';
|
||||
import { OpeningTrainingProvider } from '@/contexts/OpeningTrainingContext';
|
||||
import OpeningTrainer from '@/components/OpeningTrainer/OpeningTrainer';
|
||||
import { OpeningTrainerErrorBoundary } from '@/components/OpeningTrainer/ErrorBoundary';
|
||||
import { getOpeningByEco } from '@/lib/openingTrainer/openingLoader';
|
||||
import { Personality, PERSONALITIES } from '@/lib/personalities';
|
||||
|
||||
export default function OpeningTrainingPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const openingId = params.openingId as string;
|
||||
|
||||
const [language, setLanguage] = useState<SupportedLanguage>('en');
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [opening, setOpening] = useState<OpeningMetadata | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [selectedPersonality, setSelectedPersonality] = useState<Personality>(PERSONALITIES[0]);
|
||||
const [apiKey, setApiKey] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
const storedLang = localStorage.getItem('chess_tutor_language');
|
||||
if (storedLang) setLanguage(storedLang as SupportedLanguage);
|
||||
|
||||
// Load API key
|
||||
const storedApiKey = localStorage.getItem('gemini_api_key');
|
||||
if (storedApiKey) setApiKey(storedApiKey);
|
||||
|
||||
// Load personality
|
||||
const storedPersonalityId = localStorage.getItem('chess_tutor_personality');
|
||||
if (storedPersonalityId) {
|
||||
const personality = PERSONALITIES.find(p => p.id === storedPersonalityId);
|
||||
if (personality) setSelectedPersonality(personality);
|
||||
}
|
||||
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const t = useTranslation(language);
|
||||
|
||||
useEffect(() => {
|
||||
if (mounted) {
|
||||
loadOpening();
|
||||
}
|
||||
}, [openingId, mounted]);
|
||||
|
||||
const loadOpening = () => {
|
||||
setIsLoading(true);
|
||||
|
||||
// Find opening by ECO code
|
||||
const foundOpening = getOpeningByEco(openingId);
|
||||
|
||||
if (!foundOpening) {
|
||||
// Opening not found - redirect back to selection
|
||||
router.push('/learning/openings');
|
||||
return;
|
||||
}
|
||||
|
||||
setOpening(foundOpening);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
<Header language={language} />
|
||||
<div className="flex-grow bg-gray-100 dark:bg-gray-900 p-4 flex flex-col">
|
||||
<div className="max-w-6xl mx-auto w-full">
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center space-y-4">
|
||||
<div className="w-12 h-12 border-4 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto"></div>
|
||||
<p className="text-gray-600 dark:text-gray-400">Loading opening training session...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!opening) {
|
||||
return (
|
||||
<>
|
||||
<Header language={language} />
|
||||
<div className="flex-grow bg-gray-100 dark:bg-gray-900 p-4 flex flex-col">
|
||||
<div className="max-w-6xl mx-auto w-full">
|
||||
<div className="text-center py-12">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Opening Not Found
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
The requested opening could not be found.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.push('/learning/openings')}
|
||||
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Back to Opening Selection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header language={language} />
|
||||
<div className="flex-grow bg-gray-100 dark:bg-gray-900 p-4 flex flex-col">
|
||||
<div className="max-w-6xl mx-auto w-full">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => router.push('/learning/openings')}
|
||||
className="p-2 md:px-4 md:py-2 bg-gray-200 dark:bg-gray-700 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 text-sm font-medium transition-colors flex items-center gap-2"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
<span className="hidden md:inline">Back to Opening Selection</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">{opening.name}</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">ECO: {opening.eco}</p>
|
||||
</div>
|
||||
|
||||
<OpeningTrainerErrorBoundary>
|
||||
<OpeningTrainingProvider>
|
||||
<OpeningTrainer
|
||||
opening={opening}
|
||||
personality={selectedPersonality}
|
||||
apiKey={apiKey}
|
||||
language={language}
|
||||
/>
|
||||
</OpeningTrainingProvider>
|
||||
</OpeningTrainerErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import Header from '@/components/Header';
|
||||
import OpeningSelector from '@/components/OpeningTrainer/OpeningSelector';
|
||||
import FamilySelector from '@/components/OpeningTrainer/FamilySelector';
|
||||
import { useTranslation } from '@/lib/i18n/useTranslation';
|
||||
import { SupportedLanguage } from '@/lib/i18n/translations';
|
||||
import { getEcoRootOpenings } from '@/lib/openingTrainer/openingLoader';
|
||||
import { groupOpeningsByFamily } from '@/lib/openingTrainer/openingFamilies';
|
||||
|
||||
export default function OpeningsPage() {
|
||||
const router = useRouter();
|
||||
const [language, setLanguage] = useState<SupportedLanguage>('en');
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [selectedFamily, setSelectedFamily] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const storedLang = localStorage.getItem('chess_tutor_language');
|
||||
if (storedLang) setLanguage(storedLang as SupportedLanguage);
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const t = useTranslation(language);
|
||||
|
||||
// Get ECO root openings for display
|
||||
const allOpenings = useMemo(() => {
|
||||
return getEcoRootOpenings();
|
||||
}, []);
|
||||
|
||||
// Group openings by family
|
||||
const openingFamilies = useMemo(() => {
|
||||
return groupOpeningsByFamily(allOpenings);
|
||||
}, [allOpenings]);
|
||||
|
||||
const handleSelectFamily = (familyName: string) => {
|
||||
setSelectedFamily(familyName);
|
||||
};
|
||||
|
||||
const handleBackToFamilies = () => {
|
||||
setSelectedFamily(null);
|
||||
};
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header language={language} />
|
||||
<div className="flex-grow bg-gray-100 dark:bg-gray-900 p-4 flex flex-col">
|
||||
<div className="max-w-6xl mx-auto w-full">
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => router.push('/learning')}
|
||||
className="p-2 md:px-4 md:py-2 bg-gray-200 dark:bg-gray-700 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 text-sm font-medium transition-colors flex items-center gap-2"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
<span className="hidden md:inline">{t.learning.backToMenu}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!selectedFamily ? (
|
||||
<>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Opening Training
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-8">
|
||||
Select an opening family to explore. Each family contains multiple variations
|
||||
with engine-backed feedback and AI-powered explanations.
|
||||
</p>
|
||||
|
||||
<FamilySelector
|
||||
families={openingFamilies}
|
||||
onSelectFamily={handleSelectFamily}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<OpeningSelector
|
||||
openings={allOpenings}
|
||||
selectedFamily={selectedFamily}
|
||||
onBackToFamilies={handleBackToFamilies}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -145,11 +145,24 @@ export default function LearningAreaPage() {
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 p-8 rounded-xl border-2 border-dashed border-gray-300 dark:border-gray-600 text-center">
|
||||
<p className="text-gray-500 dark:text-gray-400 text-lg">
|
||||
{t.learning.comingSoon}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => router.push('/learning/openings')}
|
||||
className="w-full group bg-white dark:bg-gray-800 p-8 rounded-xl hover:bg-purple-50 dark:hover:bg-gray-700 transition-all border-2 border-transparent hover:border-purple-500 dark:hover:border-purple-400 shadow-sm hover:shadow-md text-left"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-5xl group-hover:scale-110 transition-transform">
|
||||
📖
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900 dark:text-white text-xl mb-2">
|
||||
Opening Training
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Practice opening repertoire with engine-backed feedback and AI explanations
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// Server component layout for static export
|
||||
// This allows generateStaticParams while the page remains a client component
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const patterns = ['pin', 'skewer', 'fork', 'discovered_attack', 'discovered_check'];
|
||||
return patterns.map((pattern) => ({
|
||||
pattern,
|
||||
}));
|
||||
}
|
||||
|
||||
export default function TacticsPatternLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return children;
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { Chess, Move } from "chess.js";
|
||||
import { Chess, Move, Square } from "chess.js";
|
||||
import { Chessboard } from "react-chessboard";
|
||||
import { ArrowLeft, CheckCircle, XCircle, RefreshCw, SkipForward } from "lucide-react";
|
||||
import Header from "@/components/Header";
|
||||
@@ -153,7 +153,7 @@ export default function TacticalPracticePage() {
|
||||
if (setupError) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<Header language={language} onLanguageChange={setLanguage} />
|
||||
<Header language={language} />
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-6">
|
||||
@@ -218,7 +218,7 @@ export default function TacticalPracticePage() {
|
||||
return t.learning.patterns[mapping[pattern]];
|
||||
};
|
||||
|
||||
const onDrop = ({ sourceSquare, targetSquare }: { sourceSquare: string; targetSquare: string | null }) => {
|
||||
const onDrop = ({ sourceSquare, targetSquare }: { sourceSquare: Square; targetSquare: Square | null }) => {
|
||||
if (!targetSquare || feedback !== 'none') return false;
|
||||
|
||||
// Additional validation: Check if there's actually a piece on the source square
|
||||
@@ -571,7 +571,7 @@ export default function TacticalPracticePage() {
|
||||
position: fen,
|
||||
onPieceDrop: ({ sourceSquare, targetSquare }) => {
|
||||
console.log('🎲 onPieceDrop called with:', { sourceSquare, targetSquare });
|
||||
return onDrop({ sourceSquare, targetSquare });
|
||||
return onDrop({ sourceSquare: sourceSquare as Square, targetSquare: targetSquare as Square | null });
|
||||
},
|
||||
darkSquareStyle: { backgroundColor: '#779954' },
|
||||
lightSquareStyle: { backgroundColor: '#e9edcc' },
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
'use client';
|
||||
|
||||
import { AlertCircle, ExternalLink, X, Settings, Key } from 'lucide-react';
|
||||
import { GeminiErrorInfo } from '@/lib/geminiErrorHandler';
|
||||
import { ApiKeyInfo, getApiKeySourceDescription } from '@/lib/apiKeyHelper';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
interface GeminiErrorModalProps {
|
||||
error: GeminiErrorInfo;
|
||||
apiKeyInfo: ApiKeyInfo;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function GeminiErrorModal({ error, apiKeyInfo, onClose }: GeminiErrorModalProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const handleGoToSettings = () => {
|
||||
onClose();
|
||||
router.push('/settings');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center">
|
||||
<AlertCircle className="text-red-600 dark:text-red-400" size={24} />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{error.isQuotaError ? 'API Quota Exceeded' : 'API Error'}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-4">
|
||||
{/* API Key Info */}
|
||||
<div className="bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Key className="text-gray-600 dark:text-gray-400 flex-shrink-0 mt-0.5" size={18} />
|
||||
<div className="flex-1 space-y-1">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
Current API Key:
|
||||
</p>
|
||||
<p className="text-sm font-mono text-gray-700 dark:text-gray-300">
|
||||
{apiKeyInfo.anonymized}
|
||||
</p>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||
Source: {getApiKeySourceDescription(apiKeyInfo.source)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
{error.userMessage}
|
||||
</p>
|
||||
|
||||
{error.retryAfterSeconds && (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-300">
|
||||
You can try again in <strong>{error.retryAfterSeconds} seconds</strong>.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error.isQuotaError && (
|
||||
<div className="space-y-3">
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
|
||||
<p className="text-sm text-yellow-800 dark:text-yellow-300 mb-2">
|
||||
<strong>Solutions:</strong>
|
||||
</p>
|
||||
<ul className="text-sm text-yellow-800 dark:text-yellow-300 list-disc list-inside space-y-1">
|
||||
{apiKeyInfo.source === 'localStorage' && (
|
||||
<li>Change your API key to a different one with available quota</li>
|
||||
)}
|
||||
<li>Upgrade to a paid Gemini API plan for higher quotas</li>
|
||||
<li>Wait until tomorrow for your free tier quota to reset</li>
|
||||
<li>Use the chess tutor less frequently throughout the day</li>
|
||||
{apiKeyInfo.source === 'env' && (
|
||||
<li>Update the NEXT_PUBLIC_GEMINI_API_KEY environment variable</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Change API Key button (if from localStorage) */}
|
||||
{apiKeyInfo.source === 'localStorage' && (
|
||||
<button
|
||||
onClick={handleGoToSettings}
|
||||
className="flex items-center justify-center gap-2 w-full px-4 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium"
|
||||
>
|
||||
<Settings size={16} />
|
||||
Change API Key in Settings
|
||||
</button>
|
||||
)}
|
||||
|
||||
<a
|
||||
href="https://ai.google.dev/pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-2 w-full px-4 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
|
||||
>
|
||||
View Gemini API Pricing
|
||||
<ExternalLink size={16} />
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://ai.dev/usage"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-2 w-full px-4 py-3 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors font-medium"
|
||||
>
|
||||
Check Your API Usage
|
||||
<ExternalLink size={16} />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Technical details (collapsible) */}
|
||||
<details className="mt-4">
|
||||
<summary className="text-sm text-gray-500 dark:text-gray-400 cursor-pointer hover:text-gray-700 dark:hover:text-gray-300">
|
||||
Technical details
|
||||
</summary>
|
||||
<pre className="mt-2 p-3 bg-gray-100 dark:bg-gray-900 rounded text-xs text-gray-600 dark:text-gray-400 overflow-x-auto">
|
||||
{error.technicalMessage}
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-6 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors font-medium"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
'use client';
|
||||
|
||||
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error Boundary for Opening Trainer components
|
||||
* Catches and handles runtime errors gracefully
|
||||
*/
|
||||
export class OpeningTrainerErrorBoundary extends Component<Props, State> {
|
||||
public state: State = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
public static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error('Opening Trainer Error:', error, errorInfo);
|
||||
}
|
||||
|
||||
private handleReset = () => {
|
||||
this.setState({ hasError: false, error: null });
|
||||
// Reload the page to restart the training session
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
public render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-[400px] flex items-center justify-center p-8">
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-6 max-w-2xl">
|
||||
<h2 className="text-xl font-bold text-red-900 mb-4">
|
||||
Something went wrong
|
||||
</h2>
|
||||
<p className="text-red-700 mb-4">
|
||||
{this.state.error?.message ||
|
||||
'An unexpected error occurred in the opening trainer.'}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
onClick={this.handleReset}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 font-medium"
|
||||
>
|
||||
Restart Training Session
|
||||
</button>
|
||||
<button
|
||||
onClick={() => (window.location.href = '/learning/openings')}
|
||||
className="ml-2 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 font-medium"
|
||||
>
|
||||
Back to Opening Selection
|
||||
</button>
|
||||
</div>
|
||||
{process.env.NODE_ENV === 'development' && this.state.error && (
|
||||
<details className="mt-4 text-sm text-gray-600">
|
||||
<summary className="cursor-pointer font-medium">
|
||||
Error Details (Development Only)
|
||||
</summary>
|
||||
<pre className="mt-2 p-2 bg-gray-100 rounded overflow-auto">
|
||||
{this.state.error.stack}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { OpeningFamily } from '@/lib/openingTrainer/openingFamilies';
|
||||
|
||||
interface FamilySelectorProps {
|
||||
families: OpeningFamily[];
|
||||
onSelectFamily: (familyName: string) => void;
|
||||
}
|
||||
|
||||
export default function FamilySelector({ families, onSelectFamily }: FamilySelectorProps) {
|
||||
// Group families by ECO range for display
|
||||
const groupedFamilies = useMemo(() => {
|
||||
const groups: Record<string, OpeningFamily[]> = {
|
||||
'White Openings (1.e4)': [],
|
||||
'White Openings (1.d4)': [],
|
||||
'Black Defenses vs 1.e4': [],
|
||||
'Black Defenses vs 1.d4': [],
|
||||
'Other Openings': [],
|
||||
};
|
||||
|
||||
families.forEach(family => {
|
||||
const firstEco = family.ecoRange[0];
|
||||
|
||||
if (firstEco === 'C') {
|
||||
groups['White Openings (1.e4)'].push(family);
|
||||
} else if (firstEco === 'D') {
|
||||
groups['White Openings (1.d4)'].push(family);
|
||||
} else if (firstEco === 'B') {
|
||||
groups['Black Defenses vs 1.e4'].push(family);
|
||||
} else if (firstEco === 'E') {
|
||||
groups['Black Defenses vs 1.d4'].push(family);
|
||||
} else {
|
||||
groups['Other Openings'].push(family);
|
||||
}
|
||||
});
|
||||
|
||||
return groups;
|
||||
}, [families]);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{Object.entries(groupedFamilies).map(([category, categoryFamilies]) => {
|
||||
if (categoryFamilies.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div key={category} className="space-y-4">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{category} ({categoryFamilies.length})
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{categoryFamilies.map((family) => (
|
||||
<button
|
||||
key={family.name}
|
||||
onClick={() => onSelectFamily(family.name)}
|
||||
className="block p-6 border-2 border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg hover:border-blue-500 dark:hover:border-blue-400 hover:shadow-lg transition-all text-left"
|
||||
aria-label={`Select ${family.name} opening family`}
|
||||
>
|
||||
<h3 className="font-bold text-lg text-gray-900 dark:text-white mb-3">
|
||||
{family.name}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">Variations:</span>
|
||||
<span className="font-semibold text-blue-600 dark:text-blue-400">
|
||||
{family.variationCount}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">ECO Range:</span>
|
||||
<span className="font-mono text-xs text-gray-700 dark:text-gray-300">
|
||||
{family.ecoRange}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">Total Moves:</span>
|
||||
<span className="font-semibold text-gray-700 dark:text-gray-300">
|
||||
{family.totalMoves}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Popularity indicator */}
|
||||
{family.popularity >= 2.5 && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||
<span className="inline-flex items-center text-xs font-medium text-green-700 dark:text-green-400 bg-green-100 dark:bg-green-900/30 px-2 py-1 rounded">
|
||||
⭐ Popular
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
'use client';
|
||||
|
||||
import { MoveFeedback as MoveFeedbackType } from '@/types/openingTraining';
|
||||
import { formatEvaluation } from '@/lib/openingTrainer/moveValidator';
|
||||
|
||||
interface MoveFeedbackProps {
|
||||
feedback: MoveFeedbackType;
|
||||
}
|
||||
|
||||
export default function MoveFeedback({ feedback }: MoveFeedbackProps) {
|
||||
const { move, classification, evaluation, previousEvaluation, llmExplanation } =
|
||||
feedback;
|
||||
|
||||
// Format evaluation change
|
||||
const evalChange = classification.evaluationChange !== 0
|
||||
? `${classification.evaluationChange > 0 ? '+' : ''}${(classification.evaluationChange / 100).toFixed(2)}`
|
||||
: '0.00';
|
||||
|
||||
// Category badge styling
|
||||
const categoryStyles = {
|
||||
'in-theory': 'bg-green-100 text-green-800 border-green-300',
|
||||
playable: 'bg-yellow-100 text-yellow-800 border-yellow-300',
|
||||
weak: 'bg-red-100 text-red-800 border-red-300',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-lg p-4 space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-gray-900">
|
||||
Move {move.moveNumber}
|
||||
{move.color === 'white' ? '.' : '...'} {move.san}
|
||||
</h3>
|
||||
<div
|
||||
className={`px-3 py-1 rounded border text-sm font-medium ${
|
||||
categoryStyles[classification.category]
|
||||
}`}
|
||||
>
|
||||
{classification.category.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Classification details */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span
|
||||
className={`inline-block w-2 h-2 rounded-full ${
|
||||
classification.inRepertoire ? 'bg-green-500' : 'bg-gray-400'
|
||||
}`}
|
||||
></span>
|
||||
<span className="text-gray-700">
|
||||
{classification.inRepertoire
|
||||
? 'In repertoire'
|
||||
: 'Outside repertoire'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{classification.theoreticalAlternatives &&
|
||||
classification.theoreticalAlternatives.length > 0 && (
|
||||
<div className="text-sm">
|
||||
<span className="text-gray-600">Repertoire alternatives: </span>
|
||||
<span className="font-mono text-gray-900">
|
||||
{classification.theoreticalAlternatives.join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Engine evaluation */}
|
||||
<div className="bg-gray-50 rounded-lg p-3 space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-600 font-medium">Evaluation:</span>
|
||||
<span className="font-mono text-gray-900 font-semibold">
|
||||
{formatEvaluation(evaluation)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{evaluation.bestMove && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-600 font-medium">Engine best:</span>
|
||||
<span className="font-mono text-gray-900">
|
||||
{evaluation.bestMove}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{evalChange && (
|
||||
<div
|
||||
className={`flex items-center justify-between text-sm ${
|
||||
classification.isSignificantSwing ? 'font-bold' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="text-gray-600 font-medium">
|
||||
Eval change:
|
||||
{classification.isSignificantSwing && (
|
||||
<span className="ml-1 text-xs bg-orange-100 text-orange-800 px-1 rounded">
|
||||
Significant
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={`font-mono ${
|
||||
evalChange.startsWith('+')
|
||||
? 'text-green-700'
|
||||
: 'text-red-700'
|
||||
}`}
|
||||
>
|
||||
{evalChange}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* LLM Explanation */}
|
||||
{llmExplanation && (
|
||||
<div className="pt-3 border-t border-gray-200">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex-shrink-0 w-6 h-6 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<span className="text-blue-600 text-xs font-bold">AI</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-gray-700 leading-relaxed">
|
||||
{llmExplanation}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading state for explanation */}
|
||||
{!llmExplanation && classification.category !== 'in-theory' && (
|
||||
<div className="pt-3 border-t border-gray-200">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<div className="w-4 h-4 border-2 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
|
||||
<span>Generating explanation...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { OpeningMetadata } from '@/lib/openings';
|
||||
import { loadSession } from '@/lib/openingTrainer/sessionManager';
|
||||
|
||||
interface OpeningSelectorProps {
|
||||
openings: OpeningMetadata[];
|
||||
selectedFamily?: string;
|
||||
onBackToFamilies?: () => void;
|
||||
}
|
||||
|
||||
// Helper function to count moves in an opening
|
||||
const countMoves = (movesString: string): number => {
|
||||
if (!movesString) return 0;
|
||||
// Filter out move numbers (e.g., "1.", "2.") and count actual moves
|
||||
const moves = movesString.split(' ').filter(m => !m.match(/^\d+\.$/));
|
||||
return moves.length;
|
||||
};
|
||||
|
||||
// Helper function to extract variation name (after family prefix)
|
||||
const getVariationName = (fullName: string, familyName?: string): string => {
|
||||
if (!familyName) return fullName;
|
||||
|
||||
// Remove family prefix and common delimiters
|
||||
const separators = [':', ',', '–', '—', ' - '];
|
||||
for (const sep of separators) {
|
||||
if (fullName.includes(sep)) {
|
||||
const parts = fullName.split(sep);
|
||||
if (parts.length > 1) {
|
||||
return parts.slice(1).join(sep).trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no separator found, return full name
|
||||
return fullName;
|
||||
};
|
||||
|
||||
// Helper function to determine opening popularity for sorting
|
||||
const getPopularityScore = (eco: string): number => {
|
||||
// Very popular openings (most common in practice)
|
||||
const veryPopular = ['C50', 'C55', 'C60', 'C65', 'C80', 'C90', 'D00', 'D06', 'D30', 'D35', 'D37', 'E00', 'E20', 'E60', 'E90', 'B10', 'B12', 'B20', 'B30', 'B33', 'B40', 'B50', 'B90'];
|
||||
if (veryPopular.some(code => eco.startsWith(code))) return 3;
|
||||
|
||||
// Popular openings
|
||||
const popular = ['A00', 'A04', 'A10', 'A40', 'A45', 'C00', 'C01', 'C02', 'C10', 'C15', 'C20', 'C30', 'C40', 'D10', 'D20', 'D40', 'D50', 'D60', 'D70', 'D80', 'E10', 'E30', 'E40', 'E50', 'E70', 'B00', 'B01', 'B02'];
|
||||
if (popular.some(code => eco.startsWith(code))) return 2;
|
||||
|
||||
// Less common
|
||||
return 1;
|
||||
};
|
||||
|
||||
export default function OpeningSelector({ openings, selectedFamily, onBackToFamilies }: OpeningSelectorProps) {
|
||||
const [colorFilter, setColorFilter] = useState<'all' | 'white' | 'black'>('all');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// Helper to extract family name from opening name
|
||||
const extractFamilyName = (openingName: string): string => {
|
||||
const separators = [':', ',', '–', '—', ' - '];
|
||||
for (const sep of separators) {
|
||||
if (openingName.includes(sep)) {
|
||||
return openingName.split(sep)[0].trim();
|
||||
}
|
||||
}
|
||||
return openingName;
|
||||
};
|
||||
|
||||
// Filter openings based on color, search query, family, and move count
|
||||
const filteredOpenings = useMemo(() => {
|
||||
return openings.filter((opening) => {
|
||||
// Filter out openings with only 1 move (not useful for training)
|
||||
const moveCount = countMoves(opening.moves);
|
||||
if (moveCount <= 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Family filter (if a family is selected)
|
||||
if (selectedFamily) {
|
||||
const family = extractFamilyName(opening.name);
|
||||
if (family !== selectedFamily) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Color filter (based on ECO code patterns)
|
||||
// A00-A99, B00-B99, C00-C99 are generally White openings
|
||||
// D00-D99, E00-E99 are generally Black defenses
|
||||
if (colorFilter !== 'all') {
|
||||
const ecoLetter = opening.eco[0];
|
||||
if (colorFilter === 'white' && !['A', 'B', 'C'].includes(ecoLetter)) {
|
||||
return false;
|
||||
}
|
||||
if (colorFilter === 'black' && !['D', 'E'].includes(ecoLetter)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Search filter
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
return (
|
||||
opening.name.toLowerCase().includes(query) ||
|
||||
opening.eco.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}).sort((a, b) => {
|
||||
// Sort by move count (descending) when family is selected, otherwise by popularity
|
||||
if (selectedFamily) {
|
||||
const movesA = countMoves(a.moves);
|
||||
const movesB = countMoves(b.moves);
|
||||
if (movesA !== movesB) {
|
||||
return movesB - movesA; // More moves first
|
||||
}
|
||||
} else {
|
||||
const scoreA = getPopularityScore(a.eco);
|
||||
const scoreB = getPopularityScore(b.eco);
|
||||
if (scoreA !== scoreB) {
|
||||
return scoreB - scoreA; // Higher score first
|
||||
}
|
||||
}
|
||||
return a.eco.localeCompare(b.eco); // Alphabetical by ECO
|
||||
});
|
||||
}, [openings, colorFilter, searchQuery, selectedFamily]);
|
||||
|
||||
// Group openings by ECO family (first letter)
|
||||
const groupedOpenings = useMemo(() => {
|
||||
const groups: Record<string, OpeningMetadata[]> = {
|
||||
A: [],
|
||||
B: [],
|
||||
C: [],
|
||||
D: [],
|
||||
E: [],
|
||||
};
|
||||
|
||||
filteredOpenings.forEach((opening) => {
|
||||
const family = opening.eco[0];
|
||||
if (groups[family]) {
|
||||
groups[family].push(opening);
|
||||
}
|
||||
});
|
||||
|
||||
return groups;
|
||||
}, [filteredOpenings]);
|
||||
|
||||
// Check if an opening has an active session
|
||||
const hasActiveSession = (eco: string): boolean => {
|
||||
return loadSession(eco) !== null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Back button and header (when family is selected) */}
|
||||
{selectedFamily && onBackToFamilies && (
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={onBackToFamilies}
|
||||
className="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors font-medium"
|
||||
>
|
||||
← Back to Families
|
||||
</button>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{selectedFamily} - Select Variation
|
||||
</h2>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
{/* Color filter */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setColorFilter('all')}
|
||||
className={`px-4 py-2 rounded-lg transition-colors ${
|
||||
colorFilter === 'all'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
All Openings
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setColorFilter('white')}
|
||||
className={`px-4 py-2 rounded-lg transition-colors ${
|
||||
colorFilter === 'white'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
White
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setColorFilter('black')}
|
||||
className={`px-4 py-2 rounded-lg transition-colors ${
|
||||
colorFilter === 'black'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
Black
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search openings..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Results count */}
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Showing {filteredOpenings.length} opening{filteredOpenings.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
|
||||
{/* Grouped openings */}
|
||||
{Object.entries(groupedOpenings).map(([family, familyOpenings]) => {
|
||||
if (familyOpenings.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div key={family} className="space-y-3">
|
||||
<h3 className="text-lg font-semibold text-gray-800 dark:text-gray-200">
|
||||
ECO {family} ({familyOpenings.length})
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{familyOpenings.map((opening) => {
|
||||
const hasSession = hasActiveSession(opening.eco);
|
||||
const moveCount = countMoves(opening.moves);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={opening.eco}
|
||||
href={`/learning/openings/${opening.eco}`}
|
||||
data-opening-eco={opening.eco}
|
||||
data-testid={`opening-card-${opening.eco}`}
|
||||
className="block p-4 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg hover:border-blue-500 dark:hover:border-blue-400 hover:shadow-md transition-all"
|
||||
aria-label={`Select ${opening.name} opening`}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<h4 className="font-semibold text-gray-900 dark:text-white">
|
||||
{selectedFamily ? getVariationName(opening.name, selectedFamily) : opening.name}
|
||||
</h4>
|
||||
<div className="flex gap-2">
|
||||
{hasSession && (
|
||||
<span className="text-xs bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-400 px-2 py-1 rounded">
|
||||
In Progress
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{opening.eco}</p>
|
||||
<span className="text-xs bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-400 px-2 py-1 rounded font-medium">
|
||||
{moveCount} move{moveCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-500 font-mono truncate">
|
||||
{opening.moves.substring(0, 30)}
|
||||
{opening.moves.length > 30 ? '...' : ''}
|
||||
</p>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{filteredOpenings.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||
No openings found matching your filters.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Chess } from 'chess.js';
|
||||
import { Chessboard } from 'react-chessboard';
|
||||
import { OpeningMetadata } from '@/lib/openings';
|
||||
import { useOpeningTraining } from '@/contexts/OpeningTrainingContext';
|
||||
import { loadSession } from '@/lib/openingTrainer/sessionManager';
|
||||
import { parseMoveSequence, getUserColor } from '@/lib/openingTrainer/repertoireNavigation';
|
||||
import { getWikipediaSummary } from '@/lib/openingTrainer/wikipediaService';
|
||||
import { WikipediaSummary as WikipediaSummaryType } from '@/types/openingTraining';
|
||||
import { extractFamilyName } from '@/lib/openingTrainer/openingFamilies';
|
||||
import WikipediaSummary from './WikipediaSummary';
|
||||
import { Tutor } from '@/components/Tutor';
|
||||
import { Personality } from '@/lib/personalities';
|
||||
import { SupportedLanguage } from '@/lib/i18n/translations';
|
||||
|
||||
interface OpeningTrainerProps {
|
||||
opening: OpeningMetadata;
|
||||
personality: Personality;
|
||||
apiKey: string;
|
||||
language: SupportedLanguage;
|
||||
}
|
||||
|
||||
export default function OpeningTrainer({ opening, personality, apiKey, language }: OpeningTrainerProps) {
|
||||
const {
|
||||
session,
|
||||
chess,
|
||||
stockfish,
|
||||
currentFeedback,
|
||||
initializeSession,
|
||||
makeMove,
|
||||
navigateToMove,
|
||||
} = useOpeningTraining();
|
||||
|
||||
const [boardOrientation, setBoardOrientation] = useState<'white' | 'black'>(
|
||||
'white'
|
||||
);
|
||||
const [isInitializing, setIsInitializing] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showRecoveryDialog, setShowRecoveryDialog] = useState(false);
|
||||
const [existingSession, setExistingSession] = useState<any>(null);
|
||||
const [wikipediaSummary, setWikipediaSummary] = useState<WikipediaSummaryType | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
checkForExistingSession();
|
||||
}, [opening.eco]);
|
||||
|
||||
// Fetch Wikipedia summary for the opening (using family name for better results)
|
||||
useEffect(() => {
|
||||
const fetchWikipediaSummary = async () => {
|
||||
try {
|
||||
// Extract family name (e.g., "French Defense" from "French Defense: Exchange Variation")
|
||||
// This ensures we find the Wikipedia article for the main opening, not specific variations
|
||||
const familyName = extractFamilyName(opening.name);
|
||||
console.log(`[Wikipedia] Looking up: "${familyName}" (from "${opening.name}")`);
|
||||
|
||||
const summary = await getWikipediaSummary(familyName);
|
||||
setWikipediaSummary(summary);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch Wikipedia summary:', err);
|
||||
// Silently fail - Wikipedia is nice-to-have, not critical
|
||||
}
|
||||
};
|
||||
|
||||
fetchWikipediaSummary();
|
||||
}, [opening.name]);
|
||||
|
||||
const checkForExistingSession = () => {
|
||||
const saved = loadSession(opening.eco);
|
||||
|
||||
if (saved && saved.moveHistory.length > 0) {
|
||||
// Found existing session with moves
|
||||
setExistingSession(saved);
|
||||
setShowRecoveryDialog(true);
|
||||
setIsInitializing(false);
|
||||
} else {
|
||||
// No existing session or empty session - start fresh
|
||||
initSession(false);
|
||||
}
|
||||
};
|
||||
|
||||
const initSession = async (forceNew: boolean = false) => {
|
||||
setIsInitializing(true);
|
||||
setError(null);
|
||||
setShowRecoveryDialog(false);
|
||||
|
||||
try {
|
||||
await initializeSession(opening, forceNew);
|
||||
|
||||
// Determine board orientation from opening
|
||||
// ECO D and E are typically Black defenses
|
||||
const orientation = ['D', 'E'].includes(opening.eco[0]) ? 'black' : 'white';
|
||||
setBoardOrientation(orientation);
|
||||
} catch (err) {
|
||||
console.error('Session initialization error:', err);
|
||||
setError('Failed to initialize training session');
|
||||
} finally {
|
||||
setIsInitializing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResumeSession = () => {
|
||||
initSession(false);
|
||||
};
|
||||
|
||||
const handleStartFresh = () => {
|
||||
initSession(true);
|
||||
};
|
||||
|
||||
const handlePieceDrop = (
|
||||
sourceSquare: string,
|
||||
targetSquare: string
|
||||
): boolean => {
|
||||
if (!chess) return false;
|
||||
|
||||
try {
|
||||
// Create a temporary clone to test if the move is legal
|
||||
// WITHOUT modifying the actual chess instance
|
||||
const testChess = new Chess();
|
||||
testChess.loadPgn(chess.pgn());
|
||||
|
||||
// Try to make the move on the clone
|
||||
const move = testChess.move({
|
||||
from: sourceSquare,
|
||||
to: targetSquare,
|
||||
promotion: 'q', // Always promote to queen for simplicity
|
||||
});
|
||||
|
||||
if (move === null) {
|
||||
// Illegal move
|
||||
return false;
|
||||
}
|
||||
|
||||
// Move was legal - process it on the actual chess instance via makeMove
|
||||
makeMove(move.san);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Move error:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Session recovery dialog
|
||||
if (showRecoveryDialog && existingSession) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-center min-h-[500px]">
|
||||
<div className="bg-white rounded-lg shadow-xl p-8 max-w-md">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">
|
||||
Resume Training?
|
||||
</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
You have an existing training session for this opening with{' '}
|
||||
<span className="font-semibold">
|
||||
{existingSession.moveHistory.length} move
|
||||
{existingSession.moveHistory.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
. Would you like to resume where you left off or start fresh?
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={handleResumeSession}
|
||||
className="w-full px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium"
|
||||
>
|
||||
Resume Session
|
||||
</button>
|
||||
<button
|
||||
onClick={handleStartFresh}
|
||||
className="w-full px-6 py-3 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 font-medium"
|
||||
>
|
||||
Start Fresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-500 mt-4 text-center">
|
||||
Last updated:{' '}
|
||||
{new Date(existingSession.lastUpdated).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isInitializing) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-center min-h-[500px]">
|
||||
<div className="text-center space-y-4">
|
||||
<div className="w-12 h-12 border-4 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto"></div>
|
||||
<p className="text-gray-600 dark:text-gray-400">Initializing training session...</p>
|
||||
{!stockfish && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Loading chess engine...</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
|
||||
<h3 className="font-semibold text-red-900 mb-2">Error</h3>
|
||||
<p className="text-red-700">{error}</p>
|
||||
<button
|
||||
onClick={() => initSession()}
|
||||
className="mt-4 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session || !chess) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6 text-center">
|
||||
<p className="text-gray-600 dark:text-gray-400">No active session</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const currentPosition = chess.fen();
|
||||
const moveCount = session.moveHistory.length;
|
||||
|
||||
// Determine user's color based on opening ECO code
|
||||
const userColor = getUserColor(opening);
|
||||
|
||||
// Build opening practice mode prop for Tutor
|
||||
const repertoireMoves = parseMoveSequence(opening.moves);
|
||||
const lastMove = session.moveHistory.length > 0
|
||||
? session.moveHistory[session.moveHistory.length - 1]
|
||||
: null;
|
||||
|
||||
// Determine which color the tutor is playing
|
||||
const tutorColor = userColor === 'white' ? 'black' : 'white';
|
||||
|
||||
// Find last user move and last tutor move
|
||||
const userMoves = session.moveHistory.filter(
|
||||
m => m.color === userColor
|
||||
);
|
||||
const tutorMoves = session.moveHistory.filter(
|
||||
m => m.color === tutorColor
|
||||
);
|
||||
|
||||
const lastUserMove = userMoves.length > 0 ? userMoves[userMoves.length - 1] : null;
|
||||
const lastTutorMove = tutorMoves.length > 0 ? tutorMoves[tutorMoves.length - 1] : null;
|
||||
|
||||
const openingPracticeMode = {
|
||||
openingName: opening.name,
|
||||
openingEco: opening.eco,
|
||||
repertoireMoves,
|
||||
currentMoveIndex: session.moveHistory.length,
|
||||
isInTheory: session.deviationMoveIndex === null,
|
||||
deviationMoveIndex: session.deviationMoveIndex,
|
||||
lastUserMove: lastUserMove ? {
|
||||
from: lastUserMove.uci.substring(0, 2),
|
||||
to: lastUserMove.uci.substring(2, 4),
|
||||
san: lastUserMove.san,
|
||||
color: lastUserMove.color === 'white' ? 'w' : 'b',
|
||||
piece: lastUserMove.san[0].toLowerCase(),
|
||||
flags: '',
|
||||
captured: undefined,
|
||||
promotion: lastUserMove.uci.length > 4 ? lastUserMove.uci[4] : undefined
|
||||
} as any : null,
|
||||
lastTutorMove: lastTutorMove ? {
|
||||
from: lastTutorMove.uci.substring(0, 2),
|
||||
to: lastTutorMove.uci.substring(2, 4),
|
||||
san: lastTutorMove.san,
|
||||
color: lastTutorMove.color === 'white' ? 'w' : 'b',
|
||||
piece: lastTutorMove.san[0].toLowerCase(),
|
||||
flags: '',
|
||||
captured: undefined,
|
||||
promotion: lastTutorMove.uci.length > 4 ? lastTutorMove.uci[4] : undefined
|
||||
} as any : null,
|
||||
currentFeedback: currentFeedback ? {
|
||||
category: currentFeedback.classification.category,
|
||||
evaluationChange: currentFeedback.classification.evaluationChange,
|
||||
theoreticalAlternatives: currentFeedback.classification.theoreticalAlternatives
|
||||
} : null,
|
||||
wikipediaSummary: wikipediaSummary?.extract || undefined
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Main board area */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
{/* Board */}
|
||||
<div
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4"
|
||||
role="region"
|
||||
aria-label="Chess board"
|
||||
>
|
||||
<Chessboard
|
||||
key={currentPosition}
|
||||
options={{
|
||||
position: currentPosition,
|
||||
onPieceDrop: ({ sourceSquare, targetSquare }) => {
|
||||
if (!targetSquare) return false;
|
||||
return handlePieceDrop(sourceSquare, targetSquare);
|
||||
},
|
||||
boardOrientation: boardOrientation,
|
||||
darkSquareStyle: { backgroundColor: '#779954' },
|
||||
lightSquareStyle: { backgroundColor: '#e9edcc' },
|
||||
animationDurationInMs: 200,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Move controls */}
|
||||
<div
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4"
|
||||
role="region"
|
||||
aria-label="Move history and navigation"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">Move History</h3>
|
||||
<div className="flex gap-2" role="group" aria-label="Move navigation">
|
||||
<button
|
||||
onClick={() =>
|
||||
navigateToMove(Math.max(0, session.currentMoveIndex - 1))
|
||||
}
|
||||
disabled={session.currentMoveIndex === 0}
|
||||
className="px-3 py-1 text-sm bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
aria-label="Go to previous move"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
navigateToMove(
|
||||
Math.min(moveCount - 1, session.currentMoveIndex + 1)
|
||||
)
|
||||
}
|
||||
disabled={session.currentMoveIndex >= moveCount - 1}
|
||||
className="px-3 py-1 text-sm bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
aria-label="Go to next move"
|
||||
>
|
||||
Forward →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Move list */}
|
||||
<div
|
||||
className="space-y-2 max-h-[200px] overflow-y-auto"
|
||||
role="list"
|
||||
aria-label="List of moves played"
|
||||
>
|
||||
{moveCount === 0 ? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 text-center py-4">
|
||||
No moves yet. Make your first move!
|
||||
</p>
|
||||
) : (
|
||||
session.moveHistory.map((move, index) => (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => navigateToMove(index)}
|
||||
role="listitem"
|
||||
className={`p-2 rounded cursor-pointer transition-colors ${
|
||||
index === session.currentMoveIndex
|
||||
? 'bg-blue-100 dark:bg-blue-900/30 border border-blue-300 dark:border-blue-700'
|
||||
: 'bg-gray-50 dark:bg-gray-700/50 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
aria-label={`Move ${move.moveNumber}${
|
||||
move.color === 'white' ? '.' : '...'
|
||||
} ${move.san}, classified as ${move.classification.category}`}
|
||||
aria-current={index === session.currentMoveIndex ? 'true' : undefined}
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-mono text-sm font-semibold">
|
||||
{move.moveNumber}
|
||||
{move.color === 'white' ? '.' : '...'} {move.san}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs px-2 py-1 rounded ${
|
||||
move.classification.category === 'in-theory'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: move.classification.category === 'playable'
|
||||
? 'bg-yellow-100 text-yellow-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}
|
||||
>
|
||||
{move.classification.category}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar - tutor and info */}
|
||||
<div className="space-y-4">
|
||||
{/* Tutor Chat */}
|
||||
{apiKey ? (
|
||||
<Tutor
|
||||
game={chess}
|
||||
currentFen={currentPosition}
|
||||
userMove={null} // Will be updated in Phase 2
|
||||
computerMove={null}
|
||||
stockfish={stockfish}
|
||||
evalP0={null}
|
||||
evalP2={null}
|
||||
openingData={[]}
|
||||
missedTactics={[]}
|
||||
onAnalysisComplete={() => {}}
|
||||
apiKey={apiKey}
|
||||
personality={personality}
|
||||
language={language}
|
||||
playerColor={userColor}
|
||||
onCheckComputerMove={() => {}}
|
||||
resignationContext={null}
|
||||
openingPracticeMode={openingPracticeMode}
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className="text-center">
|
||||
<div className="text-4xl mb-4">🔑</div>
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-2">
|
||||
API Key Required
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
To chat with your coach, please set up your Gemini API key in the settings.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => window.location.href = '/onboarding'}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Set Up API Key
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Wikipedia summary */}
|
||||
<WikipediaSummary
|
||||
openingName={opening.name}
|
||||
wikipediaSlug={opening.wikipediaSlug}
|
||||
/>
|
||||
|
||||
{/* Session info */}
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Opening:</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">{opening.eco}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Moves played:</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">{moveCount}</span>
|
||||
</div>
|
||||
{session.deviationMoveIndex !== null && (
|
||||
<div className="pt-2 border-t border-gray-300 dark:border-gray-600">
|
||||
<span className="inline-block px-2 py-1 bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-400 rounded text-xs">
|
||||
Off-book since move {session.deviationMoveIndex + 1}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { WikipediaSummary as WikipediaSummaryType } from '@/types/openingTraining';
|
||||
import { getWikipediaSummary } from '@/lib/openingTrainer/wikipediaService';
|
||||
|
||||
interface WikipediaSummaryProps {
|
||||
openingName: string;
|
||||
wikipediaSlug?: string; // Preferred: direct slug from database
|
||||
}
|
||||
|
||||
export default function WikipediaSummary({ openingName, wikipediaSlug }: WikipediaSummaryProps) {
|
||||
const [summary, setSummary] = useState<WikipediaSummaryType | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSummary();
|
||||
}, [openingName, wikipediaSlug]);
|
||||
|
||||
const fetchSummary = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Use slug if provided, otherwise fall back to name lookup
|
||||
const data = await getWikipediaSummary(openingName, wikipediaSlug);
|
||||
|
||||
if (!data) {
|
||||
setError('No Wikipedia article found for this opening');
|
||||
setSummary(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setSummary(data);
|
||||
} catch (err) {
|
||||
setError('Failed to load opening background information');
|
||||
setSummary(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-4 h-4 border-2 border-blue-600 dark:border-blue-400 border-t-transparent rounded-full animate-spin"></div>
|
||||
<p className="text-sm text-blue-800 dark:text-blue-300">Loading opening background...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !summary) {
|
||||
return (
|
||||
<div className="bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
No background information available for this opening.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 space-y-3">
|
||||
<div className="flex justify-between items-start">
|
||||
<h3 className="font-semibold text-blue-900 dark:text-blue-300">{summary.title}</h3>
|
||||
<a
|
||||
href={summary.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
Wikipedia ↗
|
||||
</a>
|
||||
</div>
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed">{summary.extract}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Source: Wikipedia (cached {new Date(summary.fetchedAt).toLocaleDateString()})
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+236
-11
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Stockfish, StockfishEvaluation } from "@/lib/stockfish";
|
||||
import { StockfishEvaluation } from "@/lib/stockfish";
|
||||
import { ChessEngine } from "@/lib/engine";
|
||||
import { Chess, Move } from "chess.js";
|
||||
import { getGenAIModel } from "@/lib/gemini";
|
||||
import { ChatSession } from "@google/generative-ai";
|
||||
@@ -16,13 +17,16 @@ import { SupportedLanguage } from '@/lib/i18n/translations';
|
||||
import { DetectedTactic } from '@/lib/tacticDetection';
|
||||
import { useDebug } from '@/contexts/DebugContext';
|
||||
import { MoveHistoryItem } from './GameOverModal';
|
||||
import { parseGeminiError, GeminiErrorInfo, isGeminiError } from '@/lib/geminiErrorHandler';
|
||||
import { GeminiErrorModal } from './GeminiErrorModal';
|
||||
import { getApiKeyInfo } from '@/lib/apiKeyHelper';
|
||||
|
||||
interface TutorProps {
|
||||
game: Chess;
|
||||
currentFen: string;
|
||||
userMove: Move | null;
|
||||
computerMove: Move | null;
|
||||
stockfish: Stockfish | null;
|
||||
stockfish: ChessEngine | null;
|
||||
evalP0: StockfishEvaluation | null;
|
||||
evalP2: StockfishEvaluation | null;
|
||||
openingData: OpeningMetadata[];
|
||||
@@ -54,6 +58,22 @@ interface TutorProps {
|
||||
bestStreak: number;
|
||||
};
|
||||
};
|
||||
openingPracticeMode?: {
|
||||
openingName: string;
|
||||
openingEco: string;
|
||||
repertoireMoves: string[]; // Full sequence from opening database
|
||||
currentMoveIndex: number;
|
||||
isInTheory: boolean;
|
||||
deviationMoveIndex: number | null;
|
||||
lastUserMove: Move | null;
|
||||
lastTutorMove: Move | null;
|
||||
currentFeedback: {
|
||||
category: 'in-theory' | 'playable' | 'weak';
|
||||
evaluationChange: number;
|
||||
theoreticalAlternatives: string[];
|
||||
} | null;
|
||||
wikipediaSummary?: string; // Optional Wikipedia context
|
||||
};
|
||||
}
|
||||
|
||||
interface Message {
|
||||
@@ -62,11 +82,12 @@ interface Message {
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export function Tutor({ game, currentFen, userMove, computerMove, stockfish, evalP0, evalP2, openingData, missedTactics, onAnalysisComplete, apiKey, personality, language, playerColor, onCheckComputerMove, resignationContext, tacticalPracticeMode }: TutorProps) {
|
||||
export function Tutor({ game, currentFen, userMove, computerMove, stockfish, evalP0, evalP2, openingData, missedTactics, onAnalysisComplete, apiKey, personality, language, playerColor, onCheckComputerMove, resignationContext, tacticalPracticeMode, openingPracticeMode }: TutorProps) {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [chatSession, setChatSession] = useState<ChatSession | null>(null);
|
||||
const [geminiError, setGeminiError] = useState<GeminiErrorInfo | null>(null);
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||
const { addEntry } = useDebug();
|
||||
|
||||
@@ -84,15 +105,59 @@ export function Tutor({ game, currentFen, userMove, computerMove, stockfish, eva
|
||||
// Track the current puzzle to detect when it changes
|
||||
const currentPuzzleRef = useRef<string | null>(null);
|
||||
|
||||
// Track last opening moves to detect when new moves are made
|
||||
const lastUserMoveRef = useRef<string | null>(null);
|
||||
const lastTutorMoveRef = useRef<string | null>(null);
|
||||
|
||||
// Initialize chat session with Personality System Prompt (only once per pattern type)
|
||||
useEffect(() => {
|
||||
if (apiKey) {
|
||||
const model = getGenAIModel(apiKey, "gemini-2.5-flash");
|
||||
|
||||
// Build system prompt based on mode
|
||||
// NOTE: For tactical practice, we don't include the specific puzzle solution in the system prompt
|
||||
// Instead, we'll send it as a message when the puzzle changes
|
||||
const systemPrompt = tacticalPracticeMode ? `
|
||||
const systemPrompt = openingPracticeMode ? `
|
||||
You are a Chess Tutor helping a student learn the "${openingPracticeMode.openingName}" opening.
|
||||
You must strictly follow the personality defined below.
|
||||
|
||||
PERSONALITY:
|
||||
${personality.systemPrompt}
|
||||
|
||||
${openingPracticeMode.wikipediaSummary ? `OPENING BACKGROUND (from Wikipedia):
|
||||
${openingPracticeMode.wikipediaSummary}
|
||||
|
||||
Use this background to enrich your explanations, but keep responses concise.
|
||||
` : ''}
|
||||
|
||||
YOUR ROLE:
|
||||
You are BOTH the opponent AND the tutor in this opening training session.
|
||||
|
||||
1. OPPONENT: You are playing as ${tutorColorName} in the ${openingPracticeMode.openingName}.
|
||||
- You will make moves from the opening repertoire
|
||||
- Refer to your moves naturally ("I played e5", "My response is...")
|
||||
|
||||
2. TUTOR: You are teaching the student this opening.
|
||||
- The student is playing as ${playerColorName}
|
||||
- Explain the IDEAS behind each move, not just the moves themselves
|
||||
- When the student asks for help, ALWAYS provide guidance
|
||||
- When the student stays in theory, praise them and explain what's happening
|
||||
- When the student deviates, explain why the repertoire move is better
|
||||
|
||||
YOUR RESPONSIBILITIES:
|
||||
1. WELCOME: Start with a warm greeting and brief explanation of the ${openingPracticeMode.openingName}
|
||||
2. GUIDANCE: After each move, explain the ideas and plans
|
||||
3. ENCOURAGEMENT: Keep the student motivated while learning
|
||||
4. DEVIATION HANDLING: When the student leaves theory, gently correct them
|
||||
5. ANSWERING QUESTIONS: Always help when the student asks
|
||||
|
||||
CRITICAL RULES:
|
||||
- Be encouraging and supportive
|
||||
- Explain IDEAS and PLANS, not just moves
|
||||
- Keep responses concise (2-4 sentences)
|
||||
- Do NOT be repetitive - vary your language
|
||||
- You MUST respond in the following language: ${language.toUpperCase()}
|
||||
- NEVER mention "Stockfish", "engine", "computer", or "AI"
|
||||
- When you make a move, explain WHY briefly
|
||||
` : tacticalPracticeMode ? `
|
||||
You are a Chess Coach helping a student practice tactical patterns.
|
||||
You must strictly follow the personality defined below.
|
||||
|
||||
@@ -161,7 +226,9 @@ CRITICAL RULES:
|
||||
},
|
||||
{
|
||||
role: "model",
|
||||
parts: [{ text: tacticalPracticeMode
|
||||
parts: [{ text: openingPracticeMode
|
||||
? `Understood. I will teach you the ${openingPracticeMode.openingName} opening in ${language}. I am both your opponent and your tutor. I'll explain the ideas behind each move and help you learn this opening.`
|
||||
: tacticalPracticeMode
|
||||
? `Understood. I will help you practice ${tacticalPracticeMode.patternName} in ${language}. I'll provide hints and encouragement while maintaining my personality.`
|
||||
: `Understood. I am both the opponent (${tutorColorName}) AND your tutor. I will compete against you while teaching you to improve. I will speak in ${language} and never mention engines or AI. When you ask for help, I will always provide guidance - that's my purpose.`
|
||||
}]
|
||||
@@ -171,7 +238,18 @@ CRITICAL RULES:
|
||||
setChatSession(session);
|
||||
|
||||
// Get initial greeting in the selected language
|
||||
const greetingPrompt = tacticalPracticeMode
|
||||
const greetingPrompt = openingPracticeMode
|
||||
? `Welcome the student to learn the ${openingPracticeMode.openingName}. Briefly explain the key ideas of this opening (in 2-3 sentences).
|
||||
|
||||
IMPORTANT:
|
||||
- Clarify that YOU are playing as ${tutorColorName} and the STUDENT is playing as ${playerColorName}
|
||||
- If the student is White, make it clear THEY will make the first move, not you
|
||||
- If the student is Black, explain you'll make the first move and then they'll respond
|
||||
- Don't claim you'll make a move that the student should be making
|
||||
- Be encouraging and clear about the game flow
|
||||
|
||||
Keep it in ${language}.`
|
||||
: tacticalPracticeMode
|
||||
? `Welcome the student to practice ${tacticalPracticeMode.patternName}. Briefly explain what this tactical pattern is (in 1-2 sentences). Keep it encouraging and in ${language}.`
|
||||
: `Introduce yourself briefly to start our game. Keep it short and in ${language}.`;
|
||||
|
||||
@@ -180,14 +258,23 @@ CRITICAL RULES:
|
||||
setMessages([{ role: "model", text: greetingText, timestamp: Date.now() }]);
|
||||
}).catch(err => {
|
||||
console.error("Failed to get greeting:", err);
|
||||
|
||||
// Check if it's a Gemini API error
|
||||
if (isGeminiError(err)) {
|
||||
const errorInfo = parseGeminiError(err);
|
||||
setGeminiError(errorInfo);
|
||||
}
|
||||
|
||||
// Fallback greeting
|
||||
const fallbackText = tacticalPracticeMode
|
||||
const fallbackText = openingPracticeMode
|
||||
? `Hello! Let's learn the ${openingPracticeMode.openingName} together!`
|
||||
: tacticalPracticeMode
|
||||
? `Hello! Let's practice ${tacticalPracticeMode.patternName} together!`
|
||||
: `Hello! I am ${personality.name}. Let's play!`;
|
||||
setMessages([{ role: "model", text: fallbackText, timestamp: Date.now() }]);
|
||||
});
|
||||
}
|
||||
}, [apiKey, personality, language, playerColor, patternName]);
|
||||
}, [apiKey, personality, language, playerColor, patternName, openingPracticeMode]);
|
||||
// NOTE: Removed solutionMoveKey from dependencies - we don't want to reset chat when puzzle changes
|
||||
|
||||
// Notify tutor about new puzzle (without resetting chat)
|
||||
@@ -230,9 +317,112 @@ Acknowledge this new puzzle briefly (1 sentence) and encourage the student to fi
|
||||
setMessages(prev => [...prev, { role: "model", text: responseText, timestamp: Date.now() }]);
|
||||
}).catch(err => {
|
||||
console.error("Failed to notify about new puzzle:", err);
|
||||
|
||||
// Check if it's a Gemini API error
|
||||
if (isGeminiError(err)) {
|
||||
const errorInfo = parseGeminiError(err);
|
||||
setGeminiError(errorInfo);
|
||||
}
|
||||
});
|
||||
}, [solutionMoveKey, chatSession, tacticalPracticeMode, currentFen, language]);
|
||||
|
||||
// Automatic commentary for opening practice mode
|
||||
useEffect(() => {
|
||||
if (!chatSession || !openingPracticeMode) return;
|
||||
|
||||
const userMoveKey = openingPracticeMode.lastUserMove
|
||||
? `${openingPracticeMode.lastUserMove.san}-${openingPracticeMode.currentMoveIndex}`
|
||||
: null;
|
||||
const tutorMoveKey = openingPracticeMode.lastTutorMove
|
||||
? `${openingPracticeMode.lastTutorMove.san}-${openingPracticeMode.currentMoveIndex}`
|
||||
: null;
|
||||
|
||||
// Check if user made a new move
|
||||
if (userMoveKey && userMoveKey !== lastUserMoveRef.current) {
|
||||
lastUserMoveRef.current = userMoveKey;
|
||||
|
||||
// Generate commentary about user's move
|
||||
const feedback = openingPracticeMode.currentFeedback;
|
||||
const moveCommentary = `
|
||||
[SYSTEM TRIGGER: user_move_in_opening]
|
||||
|
||||
The student just played: ${openingPracticeMode.lastUserMove!.san}
|
||||
Move category: ${feedback?.category || 'unknown'}
|
||||
Position status: ${openingPracticeMode.isInTheory ? 'In theory' : 'Deviated from repertoire'}
|
||||
${feedback?.evaluationChange !== undefined ? `Evaluation change: ${feedback.evaluationChange.toFixed(2)}` : ''}
|
||||
${feedback?.theoreticalAlternatives && feedback.theoreticalAlternatives.length > 0 ? `Theory suggested: ${feedback.theoreticalAlternatives.join(', ')}` : ''}
|
||||
|
||||
INSTRUCTIONS:
|
||||
${openingPracticeMode.isInTheory
|
||||
? `- The student is following the repertoire correctly - praise them briefly
|
||||
- Explain the key idea behind this move (1-2 sentences)
|
||||
- If you're about to make the next move, you can mention it naturally`
|
||||
: `- The student deviated from theory
|
||||
- Gently point out what the repertoire move was
|
||||
- Explain why the repertoire move is preferred
|
||||
- Ask if they want to try again or continue exploring`}
|
||||
- Keep it concise (2-3 sentences max)
|
||||
- Stay in ${language}
|
||||
- Maintain your personality
|
||||
`.trim();
|
||||
|
||||
chatSession.sendMessage(moveCommentary).then(result => {
|
||||
const response = result.response.text();
|
||||
setMessages(prev => [...prev, { role: "model", text: response, timestamp: Date.now() }]);
|
||||
}).catch(err => {
|
||||
console.error("Failed to generate user move commentary:", err);
|
||||
if (isGeminiError(err)) {
|
||||
setGeminiError(parseGeminiError(err));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Check if tutor made a new move
|
||||
if (tutorMoveKey && tutorMoveKey !== lastTutorMoveRef.current) {
|
||||
lastTutorMoveRef.current = tutorMoveKey;
|
||||
|
||||
// Generate commentary about tutor's move
|
||||
const tutorCommentary = `
|
||||
[SYSTEM TRIGGER: tutor_move_in_opening]
|
||||
|
||||
I just played: ${openingPracticeMode.lastTutorMove!.san}
|
||||
Current position FEN: ${currentFen}
|
||||
Progress: ${openingPracticeMode.currentMoveIndex}/${openingPracticeMode.repertoireMoves.length} moves
|
||||
|
||||
INSTRUCTIONS:
|
||||
- Explain WHY you played this move (the idea behind it)
|
||||
- Mention what it accomplishes (controls center, develops, creates threat, etc.)
|
||||
- If relevant, mention what the student should think about for their next move
|
||||
- Keep it conversational and in character
|
||||
- 2-3 sentences max
|
||||
- Respond in ${language}
|
||||
|
||||
Remember: You are both the opponent AND the tutor. Explain your move as if you're teaching.
|
||||
`.trim();
|
||||
|
||||
// Add small delay before tutor explains their move
|
||||
setTimeout(() => {
|
||||
chatSession.sendMessage(tutorCommentary).then(result => {
|
||||
const response = result.response.text();
|
||||
setMessages(prev => [...prev, { role: "model", text: response, timestamp: Date.now() }]);
|
||||
}).catch(err => {
|
||||
console.error("Failed to generate tutor move commentary:", err);
|
||||
if (isGeminiError(err)) {
|
||||
setGeminiError(parseGeminiError(err));
|
||||
}
|
||||
});
|
||||
}, 300); // Brief delay so the move appears first, then the explanation
|
||||
}
|
||||
}, [
|
||||
chatSession,
|
||||
openingPracticeMode?.lastUserMove?.san,
|
||||
openingPracticeMode?.lastTutorMove?.san,
|
||||
openingPracticeMode?.currentMoveIndex,
|
||||
openingPracticeMode?.isInTheory,
|
||||
currentFen,
|
||||
language
|
||||
]);
|
||||
|
||||
// Scroll chat container to bottom (not the whole page)
|
||||
useEffect(() => {
|
||||
if (messagesContainerRef.current) {
|
||||
@@ -583,7 +773,33 @@ INSTRUCTIONS:
|
||||
setMessages(prev => [...prev, { role: "model", text: textResponse, timestamp: Date.now() }]);
|
||||
} catch (error) {
|
||||
console.error("Chat Error:", error);
|
||||
setMessages(prev => [...prev, { role: "model", text: "Sorry, I encountered an error.", timestamp: Date.now() }]);
|
||||
|
||||
// Check if it's a Gemini API error
|
||||
if (isGeminiError(error)) {
|
||||
const errorInfo = parseGeminiError(error);
|
||||
setGeminiError(errorInfo);
|
||||
|
||||
// Show a brief error message in chat
|
||||
if (errorInfo.isQuotaError) {
|
||||
setMessages(prev => [...prev, {
|
||||
role: "model",
|
||||
text: "⚠️ API quota exceeded. Please check the error message for details.",
|
||||
timestamp: Date.now()
|
||||
}]);
|
||||
} else {
|
||||
setMessages(prev => [...prev, {
|
||||
role: "model",
|
||||
text: "⚠️ I encountered an error. Please try again.",
|
||||
timestamp: Date.now()
|
||||
}]);
|
||||
}
|
||||
} else {
|
||||
setMessages(prev => [...prev, {
|
||||
role: "model",
|
||||
text: "Sorry, I encountered an error.",
|
||||
timestamp: Date.now()
|
||||
}]);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -748,6 +964,15 @@ INSTRUCTIONS:
|
||||
<Send size={20} />
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Gemini Error Modal */}
|
||||
{geminiError && (
|
||||
<GeminiErrorModal
|
||||
error={geminiError}
|
||||
apiKeyInfo={getApiKeyInfo()}
|
||||
onClose={() => setGeminiError(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,592 @@
|
||||
'use client';
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { Chess } from 'chess.js';
|
||||
import {
|
||||
TrainingSession,
|
||||
MoveHistoryEntry,
|
||||
MoveFeedback,
|
||||
} from '@/types/openingTraining';
|
||||
import { StockfishEvaluation } from '@/lib/stockfish';
|
||||
import { createEngine, ChessEngine } from '@/lib/engine';
|
||||
import { OpeningMetadata } from '@/lib/openings';
|
||||
import {
|
||||
createSession,
|
||||
loadSession,
|
||||
saveSession,
|
||||
updateSession,
|
||||
} from '@/lib/openingTrainer/sessionManager';
|
||||
import { evaluatePosition } from '@/lib/openingTrainer/engineService';
|
||||
import { classifyMove } from '@/lib/openingTrainer/moveValidator';
|
||||
import {
|
||||
getExpectedNextMoves,
|
||||
detectTransposition,
|
||||
getOpponentNextMove,
|
||||
isOpponentTurn,
|
||||
getUserColor,
|
||||
isEndOfRepertoire,
|
||||
parseMoveSequence,
|
||||
} from '@/lib/openingTrainer/repertoireNavigation';
|
||||
import { STARTING_FEN } from '@/lib/openingTrainer/constants';
|
||||
import {
|
||||
buildExplanationPrompt,
|
||||
buildTranspositionPrompt,
|
||||
ExplanationPromptContext,
|
||||
} from '@/lib/openingTrainer/feedbackGenerator';
|
||||
|
||||
/**
|
||||
* Context for managing opening training session state
|
||||
*/
|
||||
|
||||
interface OpeningTrainingContextType {
|
||||
session: TrainingSession | null;
|
||||
opening: OpeningMetadata | null;
|
||||
chess: Chess | null;
|
||||
stockfish: ChessEngine | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
currentFeedback: MoveFeedback | null;
|
||||
|
||||
// Actions
|
||||
initializeSession: (opening: OpeningMetadata, forceNew?: boolean) => Promise<void>;
|
||||
makeMove: (san: string) => Promise<void>;
|
||||
navigateToMove: (index: number) => void;
|
||||
resetSession: () => void;
|
||||
}
|
||||
|
||||
const OpeningTrainingContext = createContext<OpeningTrainingContextType | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
interface OpeningTrainingProviderProps {
|
||||
children: ReactNode;
|
||||
openingId?: string;
|
||||
}
|
||||
|
||||
export function OpeningTrainingProvider({
|
||||
children,
|
||||
openingId,
|
||||
}: OpeningTrainingProviderProps) {
|
||||
const [session, setSession] = useState<TrainingSession | null>(null);
|
||||
const [opening, setOpening] = useState<OpeningMetadata | null>(null);
|
||||
const [chess, setChess] = useState<Chess | null>(null);
|
||||
const [stockfish, setStockfish] = useState<ChessEngine | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentFeedback, setCurrentFeedback] = useState<MoveFeedback | null>(null);
|
||||
|
||||
// Cache for move feedback (indexed by move index)
|
||||
const [feedbackCache, setFeedbackCache] = useState<Map<number, MoveFeedback>>(
|
||||
new Map()
|
||||
);
|
||||
|
||||
// Initialize chess engine (local or remote based on environment)
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
createEngine().then((engine) => {
|
||||
setStockfish(engine);
|
||||
}).catch((err) => {
|
||||
console.error('Failed to create chess engine:', err);
|
||||
setError('Failed to initialize chess engine');
|
||||
});
|
||||
|
||||
return () => {
|
||||
// Cleanup will happen when stockfish changes
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Cleanup engine when it changes or component unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (stockfish) {
|
||||
stockfish.terminate();
|
||||
}
|
||||
};
|
||||
}, [stockfish]);
|
||||
|
||||
// Initialize chess instance
|
||||
useEffect(() => {
|
||||
if (session) {
|
||||
const chessInstance = new Chess(session.currentFEN);
|
||||
setChess(chessInstance);
|
||||
} else {
|
||||
setChess(null);
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
/**
|
||||
* Initialize a new training session for an opening
|
||||
*/
|
||||
const initializeSession = async (
|
||||
openingMetadata: OpeningMetadata,
|
||||
forceNew: boolean = false
|
||||
) => {
|
||||
const perfStart = performance.now();
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Try to load existing session (unless forceNew is true)
|
||||
let existingSession = forceNew ? null : loadSession(openingMetadata.eco);
|
||||
|
||||
if (existingSession && !forceNew) {
|
||||
// Resume existing session
|
||||
setSession(existingSession);
|
||||
setOpening(openingMetadata);
|
||||
} else {
|
||||
// Create new session
|
||||
const newSession = createSession(
|
||||
openingMetadata.eco,
|
||||
openingMetadata.name,
|
||||
STARTING_FEN,
|
||||
0
|
||||
);
|
||||
|
||||
// Get initial evaluation
|
||||
if (stockfish) {
|
||||
const initialEval = await evaluatePosition(STARTING_FEN, stockfish);
|
||||
newSession.initialEvaluation = initialEval.score;
|
||||
}
|
||||
|
||||
setSession(newSession);
|
||||
setOpening(openingMetadata);
|
||||
saveSession(newSession);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to initialize session');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
const perfEnd = performance.now();
|
||||
console.log(
|
||||
`[Performance] Session initialization: ${(perfEnd - perfStart).toFixed(0)}ms`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Make a move in the training session
|
||||
*/
|
||||
const makeMove = async (san: string) => {
|
||||
console.log('[OpeningTraining] makeMove called with:', san);
|
||||
|
||||
if (!session || !opening || !chess || !stockfish) {
|
||||
console.error('[OpeningTraining] Session not initialized:', { session: !!session, opening: !!opening, chess: !!chess, stockfish: !!stockfish });
|
||||
setError('Session not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
const perfStart = performance.now();
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Validate and make the move
|
||||
console.log('[OpeningTraining] Attempting to make move:', san);
|
||||
const move = chess.move(san);
|
||||
if (!move) {
|
||||
console.error('[OpeningTraining] Illegal move:', san);
|
||||
setError('Illegal move');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[OpeningTraining] Move successful:', move);
|
||||
|
||||
const newFEN = chess.fen();
|
||||
|
||||
// Get previous evaluation (from last move or initial)
|
||||
const previousEval =
|
||||
session.moveHistory.length > 0
|
||||
? session.moveHistory[session.moveHistory.length - 1].evaluation
|
||||
: { score: session.initialEvaluation, bestMove: '', ponder: null, mate: null, depth: 0 };
|
||||
|
||||
// Evaluate new position
|
||||
const evalStart = performance.now();
|
||||
const currentEval = await evaluatePosition(newFEN, stockfish);
|
||||
const evalTime = performance.now() - evalStart;
|
||||
console.log(`[Performance] Engine evaluation: ${evalTime.toFixed(0)}ms`);
|
||||
|
||||
// Check if move is in repertoire
|
||||
const expectedMoves = getExpectedNextMoves(opening, session.moveHistory.length);
|
||||
const isInRepertoire = expectedMoves.includes(san);
|
||||
|
||||
// Check for transposition (if user is off-book, see if they've transposed back)
|
||||
let transposedOpening: OpeningMetadata | null = null;
|
||||
if (session.deviationMoveIndex !== null && !isInRepertoire) {
|
||||
transposedOpening = detectTransposition(newFEN);
|
||||
}
|
||||
|
||||
// Classify the move
|
||||
const classification = classifyMove(
|
||||
san,
|
||||
isInRepertoire,
|
||||
previousEval as StockfishEvaluation,
|
||||
currentEval,
|
||||
expectedMoves
|
||||
);
|
||||
|
||||
// Create move history entry
|
||||
const moveEntry: MoveHistoryEntry = {
|
||||
moveNumber: Math.floor(session.moveHistory.length / 2) + 1,
|
||||
color: move.color === 'w' ? 'white' : 'black',
|
||||
san: move.san,
|
||||
uci: move.from + move.to + (move.promotion || ''),
|
||||
fen: newFEN,
|
||||
evaluation: currentEval,
|
||||
classification,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// Update session
|
||||
const updatedSession = {
|
||||
...session,
|
||||
currentFEN: newFEN,
|
||||
currentMoveIndex: session.moveHistory.length + 1,
|
||||
moveHistory: [...session.moveHistory, moveEntry],
|
||||
deviationMoveIndex:
|
||||
!isInRepertoire && session.deviationMoveIndex === null
|
||||
? session.moveHistory.length
|
||||
: session.deviationMoveIndex,
|
||||
};
|
||||
|
||||
console.log('[OpeningTraining] Updating session with new move. Move history length:', updatedSession.moveHistory.length);
|
||||
setSession(updatedSession);
|
||||
updateSession(updatedSession);
|
||||
|
||||
// Create initial feedback (without LLM explanation)
|
||||
const initialFeedback: MoveFeedback = {
|
||||
move: moveEntry,
|
||||
classification,
|
||||
evaluation: currentEval,
|
||||
previousEvaluation: previousEval as StockfishEvaluation,
|
||||
llmExplanation: '', // Will be populated asynchronously
|
||||
generatedAt: Date.now(),
|
||||
};
|
||||
|
||||
// Cache the initial feedback
|
||||
const moveIndex = updatedSession.moveHistory.length - 1;
|
||||
setFeedbackCache((prev) => {
|
||||
const newCache = new Map(prev);
|
||||
newCache.set(moveIndex, initialFeedback);
|
||||
return newCache;
|
||||
});
|
||||
|
||||
setCurrentFeedback(initialFeedback);
|
||||
setIsLoading(false);
|
||||
|
||||
const feedbackTime = performance.now() - perfStart;
|
||||
console.log(
|
||||
`[Performance] Move processing (engine + classification): ${feedbackTime.toFixed(0)}ms`
|
||||
);
|
||||
|
||||
// Generate LLM explanation asynchronously (don't block user)
|
||||
generateLLMExplanation(
|
||||
initialFeedback,
|
||||
updatedSession,
|
||||
moveIndex,
|
||||
session.deviationMoveIndex === null &&
|
||||
!isInRepertoire &&
|
||||
updatedSession.deviationMoveIndex !== null,
|
||||
transposedOpening
|
||||
);
|
||||
|
||||
// After user's move, check if we should make automatic opponent move
|
||||
// Only if: move was in theory, and it's opponent's turn next
|
||||
const shouldMakeOpponentMove = isInRepertoire && isOpponentTurn(opening, updatedSession.moveHistory.length);
|
||||
console.log('[OpeningTraining] Should make opponent move?', shouldMakeOpponentMove, {
|
||||
isInRepertoire,
|
||||
isOpponentTurn: isOpponentTurn(opening, updatedSession.moveHistory.length),
|
||||
moveHistoryLength: updatedSession.moveHistory.length
|
||||
});
|
||||
|
||||
if (shouldMakeOpponentMove) {
|
||||
// Add delay for natural feel (600ms)
|
||||
console.log('[OpeningTraining] Scheduling automatic opponent move in 600ms');
|
||||
setTimeout(() => {
|
||||
makeAutomaticOpponentMove(updatedSession, opening);
|
||||
}, 600);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to process move');
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Make automatic opponent move from repertoire
|
||||
*/
|
||||
const makeAutomaticOpponentMove = async (
|
||||
currentSession: TrainingSession,
|
||||
openingMetadata: OpeningMetadata
|
||||
) => {
|
||||
if (!chess || !stockfish) return;
|
||||
|
||||
try {
|
||||
// Get opponent's next move from repertoire
|
||||
const opponentMove = getOpponentNextMove(
|
||||
openingMetadata,
|
||||
currentSession.moveHistory.length
|
||||
);
|
||||
|
||||
if (!opponentMove) {
|
||||
// No opponent move available (end of repertoire)
|
||||
console.log('[AutoMove] End of repertoire - no opponent move available');
|
||||
console.log('[AutoMove] Repertoire moves:', parseMoveSequence(openingMetadata.moves));
|
||||
console.log('[AutoMove] Current move index:', currentSession.moveHistory.length);
|
||||
|
||||
// Mark that we've reached the end of repertoire
|
||||
const updatedSession = {
|
||||
...currentSession,
|
||||
// Could add a flag here if needed for UI indication
|
||||
};
|
||||
setSession(updatedSession);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we've reached end of repertoire after this move
|
||||
const willReachEnd = isEndOfRepertoire(openingMetadata, currentSession.moveHistory.length + 1);
|
||||
if (willReachEnd) {
|
||||
console.log('[AutoMove] This will be the last repertoire move');
|
||||
}
|
||||
|
||||
// Make the move on the chess instance
|
||||
const move = chess.move(opponentMove);
|
||||
if (!move) {
|
||||
console.error('[AutoMove] Failed to make opponent move:', opponentMove);
|
||||
return;
|
||||
}
|
||||
|
||||
const newFEN = chess.fen();
|
||||
|
||||
// Get previous evaluation
|
||||
const previousEval =
|
||||
currentSession.moveHistory.length > 0
|
||||
? currentSession.moveHistory[currentSession.moveHistory.length - 1].evaluation
|
||||
: { score: currentSession.initialEvaluation, bestMove: '', ponder: null, mate: null, depth: 0 };
|
||||
|
||||
// Evaluate new position
|
||||
const currentEval = await evaluatePosition(newFEN, stockfish);
|
||||
|
||||
// Classify the move (should always be "in-theory" for automatic moves)
|
||||
const expectedMoves = [opponentMove];
|
||||
const classification = classifyMove(
|
||||
opponentMove,
|
||||
true, // Always in repertoire
|
||||
previousEval as StockfishEvaluation,
|
||||
currentEval,
|
||||
expectedMoves
|
||||
);
|
||||
|
||||
// Create move history entry
|
||||
const moveEntry: MoveHistoryEntry = {
|
||||
moveNumber: Math.floor(currentSession.moveHistory.length / 2) + 1,
|
||||
color: move.color === 'w' ? 'white' : 'black',
|
||||
san: move.san,
|
||||
uci: move.from + move.to + (move.promotion || ''),
|
||||
fen: newFEN,
|
||||
evaluation: currentEval,
|
||||
classification,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// Update session
|
||||
const updatedSession = {
|
||||
...currentSession,
|
||||
currentFEN: newFEN,
|
||||
currentMoveIndex: currentSession.moveHistory.length + 1,
|
||||
moveHistory: [...currentSession.moveHistory, moveEntry],
|
||||
};
|
||||
|
||||
setSession(updatedSession);
|
||||
updateSession(updatedSession);
|
||||
|
||||
console.log(`[AutoMove] Played ${opponentMove} automatically`);
|
||||
} catch (error) {
|
||||
console.error('[AutoMove] Error making automatic opponent move:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate LLM explanation for a move asynchronously
|
||||
*/
|
||||
const generateLLMExplanation = async (
|
||||
feedback: MoveFeedback,
|
||||
currentSession: TrainingSession,
|
||||
moveIndex: number,
|
||||
isDeviationMove: boolean,
|
||||
transposedOpening: OpeningMetadata | null = null
|
||||
) => {
|
||||
if (!opening) return;
|
||||
|
||||
const llmStart = performance.now();
|
||||
|
||||
try {
|
||||
// Build the prompt
|
||||
let prompt: string;
|
||||
|
||||
if (transposedOpening) {
|
||||
// Special prompt for transposition
|
||||
prompt = buildTranspositionPrompt(transposedOpening, feedback.move.san);
|
||||
} else {
|
||||
// Normal explanation prompt
|
||||
const promptContext: ExplanationPromptContext = {
|
||||
opening,
|
||||
userMove: feedback.move,
|
||||
classification: feedback.classification,
|
||||
currentEval: feedback.evaluation,
|
||||
previousEval: feedback.previousEvaluation,
|
||||
fen: feedback.move.fen,
|
||||
moveHistory: currentSession.moveHistory,
|
||||
isDeviationMove,
|
||||
};
|
||||
|
||||
prompt = buildExplanationPrompt(promptContext);
|
||||
}
|
||||
|
||||
// Call LLM API
|
||||
const response = await fetch('/api/v1/llm/opening-explanation', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
prompt,
|
||||
moveSan: feedback.move.san,
|
||||
category: feedback.classification.category,
|
||||
theoreticalMoves: feedback.classification.theoreticalAlternatives,
|
||||
evalChange: feedback.classification.evaluationChange,
|
||||
bestMove: feedback.evaluation.bestMove,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('LLM API error:', response.statusText);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const explanation = data.explanation || '';
|
||||
|
||||
// Update feedback with explanation
|
||||
const updatedFeedback: MoveFeedback = {
|
||||
...feedback,
|
||||
llmExplanation: explanation,
|
||||
};
|
||||
|
||||
// Update cache
|
||||
setFeedbackCache((prev) => {
|
||||
const newCache = new Map(prev);
|
||||
newCache.set(moveIndex, updatedFeedback);
|
||||
return newCache;
|
||||
});
|
||||
|
||||
// Update current feedback if this is still the current move
|
||||
setCurrentFeedback((current) => {
|
||||
if (current && current.move.san === feedback.move.san) {
|
||||
return updatedFeedback;
|
||||
}
|
||||
return current;
|
||||
});
|
||||
|
||||
const llmTime = performance.now() - llmStart;
|
||||
console.log(`[Performance] LLM explanation generation: ${llmTime.toFixed(0)}ms`);
|
||||
} catch (error) {
|
||||
console.error('Failed to generate LLM explanation:', error);
|
||||
// Silently fail - user still has engine feedback
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Navigate to a specific move in the history
|
||||
*/
|
||||
const navigateToMove = (index: number) => {
|
||||
if (!session || index < 0 || index > session.moveHistory.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedSession = {
|
||||
...session,
|
||||
currentMoveIndex: index,
|
||||
currentFEN: index === 0 ? STARTING_FEN : session.moveHistory[index - 1].fen,
|
||||
};
|
||||
|
||||
setSession(updatedSession);
|
||||
|
||||
// Update feedback to show the move at this index
|
||||
if (index > 0 && index <= session.moveHistory.length) {
|
||||
const moveIndex = index - 1; // Convert from 1-based display to 0-based array index
|
||||
|
||||
// Check cache first
|
||||
const cached = feedbackCache.get(moveIndex);
|
||||
if (cached) {
|
||||
setCurrentFeedback(cached);
|
||||
return;
|
||||
}
|
||||
|
||||
// Not in cache - rebuild feedback (shouldn't happen often)
|
||||
const moveEntry = session.moveHistory[moveIndex];
|
||||
const previousEval =
|
||||
moveIndex > 0
|
||||
? session.moveHistory[moveIndex - 1].evaluation
|
||||
: { score: session.initialEvaluation, bestMove: '', ponder: null, mate: null, depth: 0 };
|
||||
|
||||
const feedback: MoveFeedback = {
|
||||
move: moveEntry,
|
||||
classification: moveEntry.classification,
|
||||
evaluation: moveEntry.evaluation,
|
||||
previousEvaluation: previousEval as StockfishEvaluation,
|
||||
llmExplanation: '', // No cached explanation available
|
||||
generatedAt: Date.now(),
|
||||
};
|
||||
|
||||
setCurrentFeedback(feedback);
|
||||
} else {
|
||||
setCurrentFeedback(null);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Reset the current session
|
||||
*/
|
||||
const resetSession = () => {
|
||||
setSession(null);
|
||||
setOpening(null);
|
||||
setCurrentFeedback(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const value: OpeningTrainingContextType = {
|
||||
session,
|
||||
opening,
|
||||
chess,
|
||||
stockfish,
|
||||
isLoading,
|
||||
error,
|
||||
currentFeedback,
|
||||
initializeSession,
|
||||
makeMove,
|
||||
navigateToMove,
|
||||
resetSession,
|
||||
};
|
||||
|
||||
return (
|
||||
<OpeningTrainingContext.Provider value={value}>
|
||||
{children}
|
||||
</OpeningTrainingContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to use the Opening Training context
|
||||
*/
|
||||
export function useOpeningTraining() {
|
||||
const context = useContext(OpeningTrainingContext);
|
||||
if (context === undefined) {
|
||||
throw new Error(
|
||||
'useOpeningTraining must be used within an OpeningTrainingProvider'
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Helper utilities for API key management
|
||||
*/
|
||||
|
||||
export type ApiKeySource = 'localStorage' | 'env' | 'none';
|
||||
|
||||
export interface ApiKeyInfo {
|
||||
key: string | null;
|
||||
source: ApiKeySource;
|
||||
anonymized: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Anonymize an API key by showing only first 2 and last 2 characters
|
||||
* Example: "AIzaSyABC...XYZ123" becomes "AI...23"
|
||||
*/
|
||||
export function anonymizeApiKey(key: string | null): string {
|
||||
if (!key || key.length < 8) {
|
||||
return '••••••••';
|
||||
}
|
||||
|
||||
const first2 = key.substring(0, 2);
|
||||
const last2 = key.substring(key.length - 2);
|
||||
return `${first2}${'•'.repeat(6)}${last2}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current API key and its source
|
||||
*/
|
||||
export function getApiKeyInfo(): ApiKeyInfo {
|
||||
// Check localStorage first (user-set key takes precedence)
|
||||
if (typeof window !== 'undefined') {
|
||||
const storedKey = localStorage.getItem('gemini_api_key');
|
||||
if (storedKey && storedKey.trim()) {
|
||||
return {
|
||||
key: storedKey,
|
||||
source: 'localStorage',
|
||||
anonymized: anonymizeApiKey(storedKey),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check environment variable (fallback)
|
||||
const envKey = process.env.NEXT_PUBLIC_GEMINI_API_KEY;
|
||||
if (envKey && envKey.trim()) {
|
||||
return {
|
||||
key: envKey,
|
||||
source: 'env',
|
||||
anonymized: anonymizeApiKey(envKey),
|
||||
};
|
||||
}
|
||||
|
||||
// No key found
|
||||
return {
|
||||
key: null,
|
||||
source: 'none',
|
||||
anonymized: '••••••••',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user-friendly description of API key source
|
||||
*/
|
||||
export function getApiKeySourceDescription(source: ApiKeySource): string {
|
||||
switch (source) {
|
||||
case 'localStorage':
|
||||
return 'User Settings (localStorage)';
|
||||
case 'env':
|
||||
return 'Environment Variable (.env)';
|
||||
case 'none':
|
||||
return 'Not configured';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Local Engine Implementation
|
||||
*
|
||||
* Uses stockfish.js running in the browser via Web Worker
|
||||
* This is GPL-licensed code - only included in web builds
|
||||
*
|
||||
* LICENSE: GPL-3.0 (because it uses stockfish.js)
|
||||
*/
|
||||
|
||||
import { Stockfish } from '../stockfish';
|
||||
import type { ChessEngine, EngineEvaluation } from './types';
|
||||
|
||||
/**
|
||||
* Local chess engine using stockfish.js
|
||||
* Runs engine in browser via Web Worker
|
||||
*
|
||||
* Only available in web builds - NOT included in mobile builds
|
||||
*/
|
||||
export class LocalEngine implements ChessEngine {
|
||||
private stockfish: Stockfish;
|
||||
|
||||
constructor() {
|
||||
this.stockfish = new Stockfish();
|
||||
}
|
||||
|
||||
async evaluate(fen: string, depth: number = 15, multiPV: number = 1): Promise<EngineEvaluation> {
|
||||
// The existing Stockfish class returns the same format
|
||||
return this.stockfish.evaluate(fen, depth, multiPV);
|
||||
}
|
||||
|
||||
terminate(): void {
|
||||
this.stockfish.terminate();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Remote Engine Implementation
|
||||
*
|
||||
* Calls API server for chess engine analysis
|
||||
* No GPL dependencies - safe for proprietary mobile builds
|
||||
*
|
||||
* LICENSE: Apache-2.0 (no GPL code)
|
||||
*/
|
||||
|
||||
import type { ChessEngine, EngineEvaluation, EngineConfig } from './types';
|
||||
|
||||
/**
|
||||
* Remote chess engine that calls API server
|
||||
* Engine runs on server, client just makes HTTP requests
|
||||
*
|
||||
* Safe for mobile apps - no GPL dependencies
|
||||
*/
|
||||
export class RemoteEngine implements ChessEngine {
|
||||
private apiUrl: string;
|
||||
private abortController: AbortController | null = null;
|
||||
|
||||
constructor(config: EngineConfig = {}) {
|
||||
// Default to current origin for web builds
|
||||
// Mobile builds should provide explicit API URL via config
|
||||
this.apiUrl = config.apiUrl ||
|
||||
(typeof window !== 'undefined' ? window.location.origin : '') ||
|
||||
process.env.NEXT_PUBLIC_API_URL ||
|
||||
'http://localhost:3050';
|
||||
}
|
||||
|
||||
async evaluate(fen: string, depth: number = 15, multiPV: number = 1): Promise<EngineEvaluation> {
|
||||
// Create new abort controller for this request
|
||||
this.abortController = new AbortController();
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.apiUrl}/api/v1/stockfish`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ fen, depth, multiPV }),
|
||||
signal: this.abortController.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Engine API error: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// API returns { evaluation: StockfishEvaluation }
|
||||
const evaluation = data.evaluation || data;
|
||||
|
||||
return {
|
||||
bestMove: evaluation.bestMove,
|
||||
ponder: evaluation.ponder,
|
||||
score: evaluation.score,
|
||||
mate: evaluation.mate,
|
||||
depth: evaluation.depth,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw new Error('Engine evaluation was cancelled');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
terminate(): void {
|
||||
// Abort any in-flight requests
|
||||
if (this.abortController) {
|
||||
this.abortController.abort();
|
||||
this.abortController = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Chess Engine Factory
|
||||
*
|
||||
* Creates appropriate engine based on environment:
|
||||
* - Web builds: Use LocalEngine (stockfish.js in browser)
|
||||
* - Mobile builds: Use RemoteEngine (calls API server)
|
||||
*
|
||||
* Configuration via environment variables:
|
||||
* - NEXT_PUBLIC_USE_REMOTE_ENGINE=true → Force remote engine
|
||||
* - NEXT_PUBLIC_API_URL → API server URL for remote engine
|
||||
*/
|
||||
|
||||
import type { ChessEngine, EngineConfig } from './types';
|
||||
|
||||
// Re-export types for convenience
|
||||
export type { ChessEngine, EngineEvaluation, EngineConfig } from './types';
|
||||
|
||||
/**
|
||||
* Create a chess engine instance
|
||||
*
|
||||
* Automatically selects Local or Remote based on environment:
|
||||
* - In web builds: Uses local Stockfish (GPL-licensed)
|
||||
* - In mobile builds: Uses remote API (no GPL dependencies)
|
||||
*
|
||||
* @param config - Optional configuration for engine
|
||||
* @returns Chess engine instance
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Automatic selection based on environment
|
||||
* const engine = createEngine();
|
||||
*
|
||||
* // Force remote engine
|
||||
* const remoteEngine = createEngine({ forceRemote: true });
|
||||
*
|
||||
* // Use custom API URL
|
||||
* const customEngine = createEngine({
|
||||
* apiUrl: 'https://api.myapp.com'
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export async function createEngine(config: EngineConfig = {}): Promise<ChessEngine> {
|
||||
// Check if we should use remote engine
|
||||
const useRemote =
|
||||
config.forceRemote ||
|
||||
process.env.NEXT_PUBLIC_USE_REMOTE_ENGINE === 'true' ||
|
||||
process.env.NEXT_PUBLIC_FORCE_REMOTE_ENGINE === 'true';
|
||||
|
||||
if (useRemote) {
|
||||
// Use remote engine (API calls)
|
||||
// Safe for mobile - no GPL dependencies
|
||||
const { RemoteEngine } = await import('./RemoteEngine');
|
||||
console.log('[Engine] Using RemoteEngine (API server)');
|
||||
return new RemoteEngine(config);
|
||||
} else {
|
||||
// Use local engine (stockfish.js in browser)
|
||||
// Only in web builds - GPL licensed
|
||||
const { LocalEngine } = await import('./LocalEngine');
|
||||
console.log('[Engine] Using LocalEngine (browser Stockfish)');
|
||||
return new LocalEngine();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if local engine is available
|
||||
* Returns false in mobile builds where stockfish.js is not included
|
||||
*/
|
||||
export function isLocalEngineAvailable(): boolean {
|
||||
return (
|
||||
typeof window !== 'undefined' &&
|
||||
process.env.NEXT_PUBLIC_USE_REMOTE_ENGINE !== 'true'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current engine type being used
|
||||
*/
|
||||
export function getEngineType(): 'local' | 'remote' {
|
||||
if (process.env.NEXT_PUBLIC_USE_REMOTE_ENGINE === 'true') {
|
||||
return 'remote';
|
||||
}
|
||||
return 'local';
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Chess Engine Abstraction Layer
|
||||
*
|
||||
* This abstraction allows us to use different engine backends:
|
||||
* - LocalEngine: Uses stockfish.js in the browser (GPL - web only)
|
||||
* - RemoteEngine: Calls API server (no GPL - mobile safe)
|
||||
*/
|
||||
|
||||
export type EngineEvaluation = {
|
||||
bestMove: string;
|
||||
ponder: string | null;
|
||||
score: number; // centipawns, positive for white
|
||||
mate: number | null; // moves to mate, positive for white
|
||||
depth: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Abstract interface for chess engines
|
||||
* Both local and remote engines implement this interface
|
||||
*/
|
||||
export interface ChessEngine {
|
||||
/**
|
||||
* Evaluate a chess position
|
||||
* @param fen - Position in FEN notation
|
||||
* @param depth - Search depth (default: 15)
|
||||
* @param multiPV - Number of principal variations (default: 1)
|
||||
* @returns Engine evaluation
|
||||
*/
|
||||
evaluate(fen: string, depth?: number, multiPV?: number): Promise<EngineEvaluation>;
|
||||
|
||||
/**
|
||||
* Terminate the engine and clean up resources
|
||||
*/
|
||||
terminate(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for engine creation
|
||||
*/
|
||||
export type EngineConfig = {
|
||||
/**
|
||||
* API URL for remote engine
|
||||
* Only used when USE_REMOTE_ENGINE is true
|
||||
*/
|
||||
apiUrl?: string;
|
||||
|
||||
/**
|
||||
* Force remote engine even if local is available
|
||||
* Used for mobile builds
|
||||
*/
|
||||
forceRemote?: boolean;
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Centralized error handler for Gemini API errors
|
||||
*/
|
||||
|
||||
export interface GeminiErrorInfo {
|
||||
isQuotaError: boolean;
|
||||
isRateLimitError: boolean;
|
||||
userMessage: string;
|
||||
technicalMessage: string;
|
||||
retryAfterSeconds?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Gemini API error and extract useful information
|
||||
*/
|
||||
export function parseGeminiError(error: any): GeminiErrorInfo {
|
||||
const errorMessage = error?.message || error?.toString() || 'Unknown error';
|
||||
|
||||
// Check for quota exceeded (429 error)
|
||||
const isQuotaError = errorMessage.includes('quota') ||
|
||||
errorMessage.includes('429') ||
|
||||
errorMessage.includes('exceeded your current quota');
|
||||
|
||||
// Check for rate limit errors
|
||||
const isRateLimitError = errorMessage.includes('rate limit') ||
|
||||
errorMessage.includes('429');
|
||||
|
||||
// Extract retry delay if available
|
||||
let retryAfterSeconds: number | undefined;
|
||||
const retryMatch = errorMessage.match(/retry in (\d+(?:\.\d+)?)s/);
|
||||
if (retryMatch) {
|
||||
retryAfterSeconds = Math.ceil(parseFloat(retryMatch[1]));
|
||||
}
|
||||
|
||||
// Generate user-friendly message
|
||||
let userMessage: string;
|
||||
|
||||
if (isQuotaError) {
|
||||
if (errorMessage.includes('free_tier')) {
|
||||
userMessage = 'You have reached the daily limit for your free API key. Please upgrade to a paid plan or wait until tomorrow to continue.';
|
||||
} else {
|
||||
userMessage = 'You have exceeded your API quota. Please check your billing details or wait before trying again.';
|
||||
}
|
||||
} else if (isRateLimitError) {
|
||||
userMessage = retryAfterSeconds
|
||||
? `You're sending requests too quickly. Please wait ${retryAfterSeconds} seconds before trying again.`
|
||||
: 'You\'re sending requests too quickly. Please wait a moment before trying again.';
|
||||
} else if (errorMessage.includes('API key')) {
|
||||
userMessage = 'Your API key appears to be invalid. Please check your settings.';
|
||||
} else if (errorMessage.includes('network') || errorMessage.includes('fetch')) {
|
||||
userMessage = 'Network error. Please check your internet connection and try again.';
|
||||
} else {
|
||||
userMessage = 'An error occurred while communicating with the AI. Please try again.';
|
||||
}
|
||||
|
||||
return {
|
||||
isQuotaError,
|
||||
isRateLimitError,
|
||||
userMessage,
|
||||
technicalMessage: errorMessage,
|
||||
retryAfterSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is a Gemini API error
|
||||
*/
|
||||
export function isGeminiError(error: any): boolean {
|
||||
const message = error?.message || error?.toString() || '';
|
||||
return message.includes('GoogleGenerativeAI') ||
|
||||
message.includes('generativelanguage.googleapis.com') ||
|
||||
message.includes('Gemini');
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Constants for the Interactive Chess Opening Training feature
|
||||
*/
|
||||
|
||||
/**
|
||||
* Move categorization thresholds
|
||||
* Based on research.md: -50cp confirmed as industry standard
|
||||
*/
|
||||
export const MOVE_CATEGORIZATION_THRESHOLDS = {
|
||||
/** Centipawn loss threshold for categorizing a move as "weak" (half a pawn) */
|
||||
WEAK_MOVE_CP_LOSS: 50,
|
||||
|
||||
/** Threshold for highlighting significant evaluation swings */
|
||||
SIGNIFICANT_SWING_CP: 50,
|
||||
|
||||
/** Centipawn equivalent for mate situations */
|
||||
MATE_SCORE_THRESHOLD: 10000,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Stockfish engine configuration
|
||||
* Based on research.md: Depth 12 targets 1.2-1.8 seconds for 2-second goal
|
||||
*/
|
||||
export const STOCKFISH_DEPTH = 12;
|
||||
|
||||
/**
|
||||
* Session persistence configuration
|
||||
*/
|
||||
export const SESSION_EXPIRY_DAYS = 7;
|
||||
|
||||
/**
|
||||
* Wikipedia cache configuration
|
||||
*/
|
||||
export const WIKIPEDIA_CACHE_DAYS = 30;
|
||||
|
||||
/**
|
||||
* Engine evaluation cache size
|
||||
* Limit to prevent excessive memory usage
|
||||
*/
|
||||
export const MAX_EVAL_CACHE_SIZE = 100;
|
||||
|
||||
/**
|
||||
* Starting position FEN
|
||||
*/
|
||||
export const STARTING_FEN = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1';
|
||||
@@ -0,0 +1,122 @@
|
||||
import { StockfishEvaluation } from '@/lib/stockfish';
|
||||
import { ChessEngine, EngineEvaluation } from '@/lib/engine';
|
||||
import { STOCKFISH_DEPTH, MAX_EVAL_CACHE_SIZE } from './constants';
|
||||
|
||||
/**
|
||||
* Engine Service for Opening Training
|
||||
* Provides position evaluation with caching to improve performance
|
||||
* Works with both local (browser) and remote (API) engines
|
||||
*/
|
||||
|
||||
// In-memory cache for position evaluations
|
||||
// Key: FEN string, Value: EngineEvaluation
|
||||
const EVAL_CACHE = new Map<string, EngineEvaluation>();
|
||||
|
||||
/**
|
||||
* Evaluate a chess position with caching
|
||||
* Checks cache first, then evaluates with engine if not cached
|
||||
*
|
||||
* @param fen - Position in FEN notation
|
||||
* @param engine - Chess engine instance (local or remote)
|
||||
* @param depth - Search depth (default from constants)
|
||||
* @returns Engine evaluation
|
||||
*/
|
||||
export async function evaluatePosition(
|
||||
fen: string,
|
||||
engine: ChessEngine,
|
||||
depth: number = STOCKFISH_DEPTH
|
||||
): Promise<EngineEvaluation> {
|
||||
// Check cache first
|
||||
if (EVAL_CACHE.has(fen)) {
|
||||
return EVAL_CACHE.get(fen)!;
|
||||
}
|
||||
|
||||
// Evaluate with engine (local or remote)
|
||||
const evaluation = await engine.evaluate(fen, depth);
|
||||
|
||||
// Cache the result
|
||||
cacheEvaluation(fen, evaluation);
|
||||
|
||||
return evaluation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an evaluation to the cache
|
||||
* Implements LRU (Least Recently Used) eviction when cache is full
|
||||
*/
|
||||
function cacheEvaluation(fen: string, evaluation: EngineEvaluation): void {
|
||||
// If cache is full, remove oldest entry (first entry in Map)
|
||||
if (EVAL_CACHE.size >= MAX_EVAL_CACHE_SIZE) {
|
||||
const firstKey = EVAL_CACHE.keys().next().value;
|
||||
if (firstKey) {
|
||||
EVAL_CACHE.delete(firstKey);
|
||||
}
|
||||
}
|
||||
|
||||
EVAL_CACHE.set(fen, evaluation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the evaluation cache
|
||||
* Useful for testing or when starting a new session
|
||||
*/
|
||||
export function clearEvaluationCache(): void {
|
||||
EVAL_CACHE.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics for debugging/monitoring
|
||||
*/
|
||||
export function getCacheStats(): {
|
||||
size: number;
|
||||
maxSize: number;
|
||||
hitRate: number;
|
||||
} {
|
||||
// Note: Hit rate tracking would require additional state
|
||||
// For now, just return size info
|
||||
return {
|
||||
size: EVAL_CACHE.size,
|
||||
maxSize: MAX_EVAL_CACHE_SIZE,
|
||||
hitRate: 0, // Would need hit/miss counters to calculate
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-cache evaluations for a sequence of positions
|
||||
* Useful for loading a repertoire line in the background
|
||||
*
|
||||
* @param fens - Array of FEN strings to evaluate
|
||||
* @param engine - Chess engine instance (local or remote)
|
||||
*/
|
||||
export async function precachePositions(
|
||||
fens: string[],
|
||||
engine: ChessEngine
|
||||
): Promise<void> {
|
||||
for (const fen of fens) {
|
||||
if (!EVAL_CACHE.has(fen)) {
|
||||
try {
|
||||
await evaluatePosition(fen, engine);
|
||||
} catch (error) {
|
||||
console.error(`Failed to precache position ${fen}:`, error);
|
||||
// Continue with next position even if one fails
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a position is already cached
|
||||
*/
|
||||
export function isPositionCached(fen: string): boolean {
|
||||
return EVAL_CACHE.has(fen);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a cached evaluation without triggering a new evaluation
|
||||
* Returns undefined if not cached
|
||||
*/
|
||||
export function getCachedEvaluation(
|
||||
fen: string
|
||||
): EngineEvaluation | undefined {
|
||||
return EVAL_CACHE.get(fen);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { MoveFeedbackClassification, MoveHistoryEntry } from '@/types/openingTraining';
|
||||
import { StockfishEvaluation } from '@/lib/stockfish';
|
||||
import { OpeningMetadata } from '@/lib/openings';
|
||||
import { formatEvaluation } from './moveValidator';
|
||||
|
||||
/**
|
||||
* Builds a prompt for the LLM to generate an educational explanation of a chess move
|
||||
*/
|
||||
|
||||
export interface ExplanationPromptContext {
|
||||
opening: OpeningMetadata;
|
||||
userMove: MoveHistoryEntry;
|
||||
classification: MoveFeedbackClassification;
|
||||
currentEval: StockfishEvaluation;
|
||||
previousEval: StockfishEvaluation;
|
||||
fen: string;
|
||||
moveHistory: MoveHistoryEntry[];
|
||||
isDeviationMove: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a structured prompt for the LLM tutor
|
||||
* The prompt ensures the LLM:
|
||||
* 1. Never contradicts engine evaluations
|
||||
* 2. Explains strategic ideas in the position
|
||||
* 3. References engine facts when discussing move quality
|
||||
*/
|
||||
export function buildExplanationPrompt(context: ExplanationPromptContext): string {
|
||||
const {
|
||||
opening,
|
||||
userMove,
|
||||
classification,
|
||||
currentEval,
|
||||
previousEval,
|
||||
fen,
|
||||
moveHistory,
|
||||
isDeviationMove,
|
||||
} = context;
|
||||
|
||||
const evalChange = classification.evaluationChange !== 0
|
||||
? `${classification.evaluationChange > 0 ? '+' : ''}${(classification.evaluationChange / 100).toFixed(2)}`
|
||||
: '0.00';
|
||||
const currentEvalStr = formatEvaluation(currentEval);
|
||||
const previousEvalStr = formatEvaluation(previousEval);
|
||||
|
||||
// Build move history string
|
||||
const movesStr = moveHistory
|
||||
.map(
|
||||
(m, idx) =>
|
||||
`${m.moveNumber}${m.color === 'white' ? '.' : '...'} ${m.san}`
|
||||
)
|
||||
.join(' ');
|
||||
|
||||
let prompt = `You are a chess opening tutor helping a student learn the ${opening.name} opening (ECO ${opening.eco}).
|
||||
|
||||
## Current Position
|
||||
FEN: ${fen}
|
||||
Move sequence: ${movesStr} ${userMove.san}
|
||||
|
||||
## The student just played: ${userMove.san}
|
||||
|
||||
## Engine Analysis (AUTHORITATIVE - never contradict this)
|
||||
- Previous evaluation: ${previousEvalStr}
|
||||
- Current evaluation: ${currentEvalStr}
|
||||
- Evaluation change: ${evalChange}
|
||||
- Best move according to engine: ${currentEval.bestMove || 'N/A'}
|
||||
`;
|
||||
|
||||
// Add context based on move category
|
||||
if (classification.category === 'in-theory') {
|
||||
prompt += `
|
||||
## Move Classification: IN THEORY
|
||||
This move is part of the opening repertoire. Explain:
|
||||
1. Why this move is played in this opening (strategic ideas, piece development, pawn structure)
|
||||
2. What the plan is after this move
|
||||
3. Common responses and how to continue
|
||||
|
||||
Keep it concise (2-3 sentences). Focus on teaching the IDEAS behind the move.`;
|
||||
} else if (classification.category === 'playable') {
|
||||
prompt += `
|
||||
## Move Classification: PLAYABLE (but not in repertoire)
|
||||
The student deviated from the repertoire, but the move is objectively sound (evaluation change: ${evalChange}).
|
||||
|
||||
${
|
||||
classification.theoreticalAlternatives.length > 0
|
||||
? `Repertoire move(s): ${classification.theoreticalAlternatives.join(', ')}`
|
||||
: ''
|
||||
}
|
||||
|
||||
Explain:
|
||||
1. Acknowledge the move is playable and why (based on engine eval)
|
||||
2. Briefly explain what the repertoire move(s) aim for
|
||||
3. How the student's move differs strategically
|
||||
|
||||
Keep it concise (2-3 sentences). Be encouraging - deviations can be learning moments!`;
|
||||
} else {
|
||||
// weak move
|
||||
prompt += `
|
||||
## Move Classification: WEAK
|
||||
The engine shows this move loses significant advantage (${evalChange}).
|
||||
|
||||
Best move was: ${currentEval.bestMove}
|
||||
${
|
||||
classification.theoreticalAlternatives.length > 0
|
||||
? `Repertoire move(s): ${classification.theoreticalAlternatives.join(', ')}`
|
||||
: ''
|
||||
}
|
||||
|
||||
Explain:
|
||||
1. Why this move is problematic (based on engine evaluation)
|
||||
2. What tactical or positional issue it creates
|
||||
3. What the better move(s) accomplish instead
|
||||
|
||||
Keep it concise (2-3 sentences). Be constructive and focus on learning.`;
|
||||
}
|
||||
|
||||
if (isDeviationMove) {
|
||||
prompt += `\n\n**NOTE**: This is the FIRST move where the student left the repertoire.`;
|
||||
}
|
||||
|
||||
prompt += `\n\n**CRITICAL RULES**:
|
||||
- NEVER contradict the engine evaluation
|
||||
- When discussing move quality, reference the engine's assessment
|
||||
- Focus on STRATEGIC IDEAS, not just memorization
|
||||
- Keep response under 60 words
|
||||
- Be encouraging and educational`;
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a simpler prompt for transposition scenarios
|
||||
*/
|
||||
export function buildTranspositionPrompt(
|
||||
transposedOpening: OpeningMetadata,
|
||||
currentMove: string
|
||||
): string {
|
||||
return `The position after ${currentMove} has transposed into the ${transposedOpening.name} (${transposedOpening.eco}).
|
||||
|
||||
Briefly explain (1 sentence):
|
||||
- What this transposition means
|
||||
- If it's a common occurrence
|
||||
|
||||
Keep under 30 words.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a prompt for explaining positions after leaving theory
|
||||
*/
|
||||
export function buildOffBookPrompt(context: ExplanationPromptContext): string {
|
||||
const { userMove, currentEval, fen } = context;
|
||||
|
||||
const currentEvalStr = formatEvaluation(currentEval);
|
||||
|
||||
return `You are a chess tutor. The student is now outside their opening repertoire after playing ${userMove.san}.
|
||||
|
||||
Position (FEN): ${fen}
|
||||
Engine evaluation: ${currentEvalStr}
|
||||
Best continuation: ${currentEval.bestMove || 'N/A'}
|
||||
|
||||
Provide a brief assessment (2 sentences):
|
||||
1. Evaluate the current position objectively
|
||||
2. Suggest a plan or strategic idea to pursue
|
||||
|
||||
Keep under 40 words. Reference the engine evaluation.`;
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import {
|
||||
MoveFeedbackClassification,
|
||||
MoveCategory,
|
||||
} from '@/types/openingTraining';
|
||||
import { StockfishEvaluation } from '@/lib/stockfish';
|
||||
import { MOVE_CATEGORIZATION_THRESHOLDS } from './constants';
|
||||
|
||||
/**
|
||||
* Move Validator Module
|
||||
* Classifies user moves relative to opening theory and objective strength
|
||||
*/
|
||||
|
||||
/**
|
||||
* Classify a user's move based on repertoire matching and engine evaluation
|
||||
*
|
||||
* @param userMove - The move the user played (SAN notation)
|
||||
* @param isInRepertoire - Whether the move matches the expected repertoire line
|
||||
* @param previousEval - Engine evaluation before the move
|
||||
* @param currentEval - Engine evaluation after the move
|
||||
* @param theoreticalMoves - List of all valid theoretical moves at this position
|
||||
* @returns Classification of the move
|
||||
*/
|
||||
export function classifyMove(
|
||||
userMove: string,
|
||||
isInRepertoire: boolean,
|
||||
previousEval: StockfishEvaluation,
|
||||
currentEval: StockfishEvaluation,
|
||||
theoreticalMoves: string[] = []
|
||||
): MoveFeedbackClassification {
|
||||
// Calculate evaluation change
|
||||
// Note: Evaluation is from White's perspective
|
||||
// A positive change means better for White, negative means better for Black
|
||||
const evalChange = currentEval.score - previousEval.score;
|
||||
|
||||
// Determine move category
|
||||
let category: MoveCategory;
|
||||
|
||||
if (isInRepertoire) {
|
||||
// Move is in the opening repertoire
|
||||
category = 'in-theory';
|
||||
} else {
|
||||
// Move is not in repertoire - check if it's weak or playable
|
||||
// A move is "weak" if it loses significant material (50cp or more)
|
||||
const cpLoss = Math.abs(evalChange);
|
||||
|
||||
if (cpLoss >= MOVE_CATEGORIZATION_THRESHOLDS.WEAK_MOVE_CP_LOSS) {
|
||||
category = 'weak';
|
||||
} else {
|
||||
category = 'playable';
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the evaluation swing is significant
|
||||
const isSignificantSwing =
|
||||
Math.abs(evalChange) >= MOVE_CATEGORIZATION_THRESHOLDS.SIGNIFICANT_SWING_CP;
|
||||
|
||||
// Filter out the user's move from theoretical alternatives
|
||||
const alternatives = theoreticalMoves.filter((move) => move !== userMove);
|
||||
|
||||
return {
|
||||
category,
|
||||
inRepertoire: isInRepertoire,
|
||||
evaluationChange: evalChange,
|
||||
isSignificantSwing,
|
||||
theoreticalAlternatives: alternatives,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a move is in the repertoire by comparing with expected next move(s)
|
||||
*
|
||||
* @param userMove - The move the user played (SAN notation)
|
||||
* @param expectedMoves - Array of expected next moves from repertoire (could be multiple variations)
|
||||
* @returns True if user's move matches any expected move
|
||||
*/
|
||||
export function isMoveinRepertoire(
|
||||
userMove: string,
|
||||
expectedMoves: string[]
|
||||
): boolean {
|
||||
return expectedMoves.some((expected) => expected === userMove);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if an evaluation represents a mate situation
|
||||
*/
|
||||
export function isMateScore(evaluation: StockfishEvaluation): boolean {
|
||||
return evaluation.mate !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a human-readable evaluation string
|
||||
*/
|
||||
export function formatEvaluation(evaluation: StockfishEvaluation): string {
|
||||
if (evaluation.mate !== null) {
|
||||
const mateIn = Math.abs(evaluation.mate);
|
||||
const side = evaluation.mate > 0 ? 'White' : 'Black';
|
||||
return `${side} mates in ${mateIn}`;
|
||||
}
|
||||
|
||||
const pawns = (evaluation.score / 100).toFixed(2);
|
||||
if (evaluation.score > 0) {
|
||||
return `+${pawns}`;
|
||||
} else if (evaluation.score < 0) {
|
||||
return pawns; // Already has minus sign
|
||||
} else {
|
||||
return '0.00';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a description of the move category for UI display
|
||||
*/
|
||||
export function getCategoryDescription(category: MoveCategory): string {
|
||||
switch (category) {
|
||||
case 'in-theory':
|
||||
return 'This move follows the opening theory';
|
||||
case 'playable':
|
||||
return 'This move is playable but not in the main repertoire';
|
||||
case 'weak':
|
||||
return 'This move is inaccurate and loses material';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a color class for styling based on category
|
||||
*/
|
||||
export function getCategoryColor(category: MoveCategory): string {
|
||||
switch (category) {
|
||||
case 'in-theory':
|
||||
return 'text-green-600';
|
||||
case 'playable':
|
||||
return 'text-yellow-600';
|
||||
case 'weak':
|
||||
return 'text-red-600';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { OpeningMetadata } from '@/lib/openings';
|
||||
|
||||
/**
|
||||
* Utilities for grouping openings into families (e.g., "Italian Game", "Sicilian Defense")
|
||||
*/
|
||||
|
||||
export interface OpeningFamily {
|
||||
name: string;
|
||||
ecoRange: string; // e.g., "C50-C59"
|
||||
variationCount: number;
|
||||
variations: OpeningMetadata[];
|
||||
totalMoves: number; // Sum of moves across all variations
|
||||
popularity: number; // Derived from ECO codes
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the family name from an opening name
|
||||
* Examples:
|
||||
* "Italian Game: Classical Variation" -> "Italian Game"
|
||||
* "Sicilian Defense, Najdorf Variation" -> "Sicilian Defense"
|
||||
* "Queen's Gambit Declined" -> "Queen's Gambit"
|
||||
*/
|
||||
export function extractFamilyName(openingName: string): string {
|
||||
// Split by common delimiters
|
||||
const separators = [':', ',', '–', '—', ' - '];
|
||||
|
||||
for (const sep of separators) {
|
||||
if (openingName.includes(sep)) {
|
||||
return openingName.split(sep)[0].trim();
|
||||
}
|
||||
}
|
||||
|
||||
// If no separator, check for common patterns
|
||||
// "Queen's Gambit Declined" -> "Queen's Gambit"
|
||||
if (openingName.includes('Declined') || openingName.includes('Accepted')) {
|
||||
return openingName.replace(/\s+(Declined|Accepted).*$/, '').trim();
|
||||
}
|
||||
|
||||
// Default: return the full name (it's probably already a family name)
|
||||
return openingName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count moves in an opening
|
||||
*/
|
||||
function countMoves(movesString: string): number {
|
||||
if (!movesString) return 0;
|
||||
const moves = movesString.split(' ').filter(m => !m.match(/^\d+\.$/));
|
||||
return moves.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine popularity score for sorting
|
||||
*/
|
||||
function getPopularityScore(ecoCode: string): number {
|
||||
// Very popular openings (most common in practice)
|
||||
const veryPopular = [
|
||||
'C50', 'C55', 'C60', 'C65', 'C80', 'C90', // Italian, Spanish
|
||||
'D00', 'D06', 'D30', 'D35', 'D37', // Queen's Gambit
|
||||
'E00', 'E20', 'E60', 'E90', // Indian Defenses
|
||||
'B10', 'B12', 'B20', 'B30', 'B33', 'B40', 'B50', 'B90', // Sicilian, Caro-Kann
|
||||
];
|
||||
if (veryPopular.some(code => ecoCode.startsWith(code))) return 3;
|
||||
|
||||
// Popular openings
|
||||
const popular = [
|
||||
'A00', 'A04', 'A10', 'A40', 'A45', // English, other flank
|
||||
'C00', 'C01', 'C02', 'C10', 'C15', 'C20', 'C30', 'C40', // French, misc 1.e4
|
||||
'D10', 'D20', 'D40', 'D50', 'D60', 'D70', 'D80', // Other Queen's pawn
|
||||
'E10', 'E30', 'E40', 'E50', 'E70', // Indian variations
|
||||
'B00', 'B01', 'B02', // Other semi-open
|
||||
];
|
||||
if (popular.some(code => ecoCode.startsWith(code))) return 2;
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group openings by family name
|
||||
*/
|
||||
export function groupOpeningsByFamily(openings: OpeningMetadata[]): OpeningFamily[] {
|
||||
// Filter out single-move openings first
|
||||
const validOpenings = openings.filter(opening => countMoves(opening.moves) > 1);
|
||||
|
||||
// Group by family name
|
||||
const familyMap = new Map<string, OpeningMetadata[]>();
|
||||
|
||||
validOpenings.forEach(opening => {
|
||||
const familyName = extractFamilyName(opening.name);
|
||||
|
||||
if (!familyMap.has(familyName)) {
|
||||
familyMap.set(familyName, []);
|
||||
}
|
||||
familyMap.get(familyName)!.push(opening);
|
||||
});
|
||||
|
||||
// Convert to OpeningFamily objects
|
||||
const families: OpeningFamily[] = [];
|
||||
|
||||
familyMap.forEach((variations, familyName) => {
|
||||
// Calculate ECO range
|
||||
const ecoCodes = variations.map(v => v.eco).sort();
|
||||
const ecoRange = ecoCodes.length === 1
|
||||
? ecoCodes[0]
|
||||
: `${ecoCodes[0]}-${ecoCodes[ecoCodes.length - 1]}`;
|
||||
|
||||
// Calculate total moves and average popularity
|
||||
const totalMoves = variations.reduce((sum, v) => sum + countMoves(v.moves), 0);
|
||||
const avgPopularity = variations.reduce((sum, v) => sum + getPopularityScore(v.eco), 0) / variations.length;
|
||||
|
||||
families.push({
|
||||
name: familyName,
|
||||
ecoRange,
|
||||
variationCount: variations.length,
|
||||
variations,
|
||||
totalMoves,
|
||||
popularity: avgPopularity,
|
||||
});
|
||||
});
|
||||
|
||||
// Sort by popularity (desc), then variation count (desc), then name
|
||||
families.sort((a, b) => {
|
||||
if (a.popularity !== b.popularity) {
|
||||
return b.popularity - a.popularity;
|
||||
}
|
||||
if (a.variationCount !== b.variationCount) {
|
||||
return b.variationCount - a.variationCount;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
return families;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all variations for a specific family
|
||||
*/
|
||||
export function getVariationsByFamily(
|
||||
openings: OpeningMetadata[],
|
||||
familyName: string
|
||||
): OpeningMetadata[] {
|
||||
return openings.filter(opening => {
|
||||
const extractedFamily = extractFamilyName(opening.name);
|
||||
return extractedFamily === familyName && countMoves(opening.moves) > 1;
|
||||
}).sort((a, b) => {
|
||||
// Sort variations by move count (desc) within family
|
||||
const movesA = countMoves(a.moves);
|
||||
const movesB = countMoves(b.moves);
|
||||
if (movesA !== movesB) {
|
||||
return movesB - movesA;
|
||||
}
|
||||
return a.eco.localeCompare(b.eco);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { OpeningMetadata } from '@/lib/openings';
|
||||
import ecoA from '../../../public/openings/ecoA.json';
|
||||
import ecoB from '../../../public/openings/ecoB.json';
|
||||
import ecoC from '../../../public/openings/ecoC.json';
|
||||
import ecoD from '../../../public/openings/ecoD.json';
|
||||
import ecoE from '../../../public/openings/ecoE.json';
|
||||
|
||||
/**
|
||||
* Centralized opening data loader to avoid duplicate imports
|
||||
*/
|
||||
|
||||
// Merge all ECO databases (keyed by FEN)
|
||||
const ALL_OPENINGS_BY_FEN = {
|
||||
...ecoA,
|
||||
...ecoB,
|
||||
...ecoC,
|
||||
...ecoD,
|
||||
...ecoE,
|
||||
} as Record<string, OpeningMetadata>;
|
||||
|
||||
// Convert to array for listing
|
||||
const OPENINGS_ARRAY = Object.values(ALL_OPENINGS_BY_FEN);
|
||||
|
||||
// Create ECO-indexed lookup for fast access (only root openings)
|
||||
const OPENINGS_BY_ECO: Record<string, OpeningMetadata> = {};
|
||||
OPENINGS_ARRAY.forEach((opening) => {
|
||||
if (opening.eco && opening.isEcoRoot === true) {
|
||||
OPENINGS_BY_ECO[opening.eco] = opening;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get all openings as an array
|
||||
*/
|
||||
export function getAllOpenings(): OpeningMetadata[] {
|
||||
return OPENINGS_ARRAY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all ECO root openings (for the selector)
|
||||
*/
|
||||
export function getEcoRootOpenings(): OpeningMetadata[] {
|
||||
return OPENINGS_ARRAY.filter((opening) => opening.isEcoRoot === true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific opening by ECO code
|
||||
* Prefers variations with more moves for better training experience
|
||||
*/
|
||||
export function getOpeningByEco(eco: string): OpeningMetadata | null {
|
||||
const rootOpening = OPENINGS_BY_ECO[eco];
|
||||
if (!rootOpening) return null;
|
||||
|
||||
// Find all openings with this ECO code
|
||||
const allVariations = OPENINGS_ARRAY.filter((opening) => opening.eco === eco);
|
||||
|
||||
// Count moves in each variation
|
||||
const variationsWithMoves = allVariations.map((opening) => ({
|
||||
opening,
|
||||
moveCount: opening.moves ? opening.moves.split(' ').filter(m => !m.match(/^\d+\.$/)).length : 0,
|
||||
}));
|
||||
|
||||
// Sort by move count descending (prefer variations with more moves)
|
||||
variationsWithMoves.sort((a, b) => b.moveCount - a.moveCount);
|
||||
|
||||
// Return the variation with the most moves (better for training)
|
||||
// But require at least 2 moves (user move + opponent response)
|
||||
const bestVariation = variationsWithMoves.find((v) => v.moveCount >= 2);
|
||||
|
||||
return bestVariation ? bestVariation.opening : rootOpening;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all openings as a record keyed by FEN (for backwards compatibility)
|
||||
*/
|
||||
export function getAllOpeningsByFen(): Record<string, OpeningMetadata> {
|
||||
return ALL_OPENINGS_BY_FEN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all openings as a record keyed by ECO code
|
||||
*/
|
||||
export function getAllOpeningsByEco(): Record<string, OpeningMetadata> {
|
||||
return OPENINGS_BY_ECO;
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { Chess } from 'chess.js';
|
||||
import { OpeningMetadata, lookupOpening } from '@/lib/openings';
|
||||
|
||||
/**
|
||||
* Repertoire Navigation Module
|
||||
* Handles tracking position within opening repertoire and detecting deviations
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse a move sequence from an opening's moves string
|
||||
* Example: "1. e4 e5 2. Nf3" → ["e4", "e5", "Nf3"]
|
||||
*
|
||||
* @param moveString - Move sequence from opening database
|
||||
* @returns Array of moves in SAN notation
|
||||
*/
|
||||
export function parseMoveSequence(moveString: string): string[] {
|
||||
if (!moveString || moveString.trim() === '') return [];
|
||||
|
||||
// Remove move numbers and extra whitespace
|
||||
// "1. e4 e5 2. Nf3" → "e4 e5 Nf3"
|
||||
const cleaned = moveString
|
||||
.replace(/\d+\./g, '') // Remove move numbers
|
||||
.replace(/\s+/g, ' ') // Normalize whitespace
|
||||
.trim();
|
||||
|
||||
// Split into individual moves
|
||||
return cleaned.split(' ').filter((move) => move.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the expected next move(s) from the repertoire at current position
|
||||
*
|
||||
* @param opening - The opening metadata from database
|
||||
* @param currentMoveIndex - Current position in the move sequence (0-based)
|
||||
* @returns Array of expected next moves (usually one, could be multiple for variations)
|
||||
*/
|
||||
export function getExpectedNextMoves(
|
||||
opening: OpeningMetadata,
|
||||
currentMoveIndex: number
|
||||
): string[] {
|
||||
const moves = parseMoveSequence(opening.moves);
|
||||
|
||||
// If we're at or past the end of the repertoire
|
||||
if (currentMoveIndex >= moves.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// For MVP: return single main line move
|
||||
// Future: could handle variations by checking for multiple lines
|
||||
const nextMove = moves[currentMoveIndex];
|
||||
return nextMove ? [nextMove] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a position has transposed into a known opening
|
||||
* Uses FEN lookup to detect if the current position exists in the database
|
||||
*
|
||||
* @param fen - Current position in FEN notation
|
||||
* @returns Opening metadata if position is found, null otherwise
|
||||
*/
|
||||
export function detectTransposition(fen: string): OpeningMetadata | null {
|
||||
return lookupOpening(fen);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the current position's FEN after making moves from the opening
|
||||
*
|
||||
* @param opening - Opening metadata
|
||||
* @param upToMoveIndex - Play moves up to this index (exclusive)
|
||||
* @returns FEN string of the resulting position
|
||||
*/
|
||||
export function buildPositionFromOpening(
|
||||
opening: OpeningMetadata,
|
||||
upToMoveIndex: number
|
||||
): string | null {
|
||||
const moves = parseMoveSequence(opening.moves);
|
||||
const chess = new Chess();
|
||||
|
||||
try {
|
||||
for (let i = 0; i < upToMoveIndex && i < moves.length; i++) {
|
||||
const move = chess.move(moves[i]);
|
||||
if (!move) {
|
||||
console.error(`Invalid move at index ${i}: ${moves[i]}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return chess.fen();
|
||||
} catch (error) {
|
||||
console.error('Error building position from opening:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total number of moves in the opening repertoire
|
||||
*/
|
||||
export function getRepertoireLength(opening: OpeningMetadata): number {
|
||||
return parseMoveSequence(opening.moves).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we've reached the end of the repertoire
|
||||
*/
|
||||
export function isEndOfRepertoire(
|
||||
opening: OpeningMetadata,
|
||||
currentMoveIndex: number
|
||||
): boolean {
|
||||
const length = getRepertoireLength(opening);
|
||||
return currentMoveIndex >= length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a visual representation of the current progress through the opening
|
||||
* Example: "5/12 moves" or "End of line"
|
||||
*/
|
||||
export function getRepertoireProgress(
|
||||
opening: OpeningMetadata,
|
||||
currentMoveIndex: number
|
||||
): string {
|
||||
const total = getRepertoireLength(opening);
|
||||
|
||||
if (currentMoveIndex >= total) {
|
||||
return 'End of repertoire';
|
||||
}
|
||||
|
||||
return `${currentMoveIndex}/${total} moves`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a move sequence matches the opening's mainline up to a certain point
|
||||
*
|
||||
* @param opening - Opening metadata
|
||||
* @param playedMoves - Array of moves that have been played (SAN notation)
|
||||
* @returns True if the played moves match the opening's sequence
|
||||
*/
|
||||
export function matchesMainline(
|
||||
opening: OpeningMetadata,
|
||||
playedMoves: string[]
|
||||
): boolean {
|
||||
const expectedMoves = parseMoveSequence(opening.moves);
|
||||
|
||||
// If we've played more moves than in the repertoire, it's not a match
|
||||
if (playedMoves.length > expectedMoves.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check each played move against expected
|
||||
for (let i = 0; i < playedMoves.length; i++) {
|
||||
if (playedMoves[i] !== expectedMoves[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine which color the user is playing based on ECO code
|
||||
* A, B, C = White openings (user plays White)
|
||||
* D, E = Black defenses (user plays Black)
|
||||
*
|
||||
* @param opening - Opening metadata
|
||||
* @returns 'white' or 'black'
|
||||
*/
|
||||
export function getUserColor(opening: OpeningMetadata): 'white' | 'black' {
|
||||
const ecoLetter = opening.eco[0];
|
||||
return ['A', 'B', 'C'].includes(ecoLetter) ? 'white' : 'black';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the opponent's next move from the repertoire
|
||||
*
|
||||
* @param opening - Opening metadata
|
||||
* @param currentMoveIndex - Current position in the move sequence (0-based)
|
||||
* @returns The opponent's next move in SAN notation, or null if not available
|
||||
*/
|
||||
export function getOpponentNextMove(
|
||||
opening: OpeningMetadata,
|
||||
currentMoveIndex: number
|
||||
): string | null {
|
||||
const moves = parseMoveSequence(opening.moves);
|
||||
const userColor = getUserColor(opening);
|
||||
|
||||
// If we're at or past the end of the repertoire
|
||||
if (currentMoveIndex >= moves.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Determine if this should be the opponent's move
|
||||
// Move index 0 = first move (1. move)
|
||||
// Move index 1 = second move (1... move)
|
||||
// etc.
|
||||
|
||||
const isWhiteMove = currentMoveIndex % 2 === 0;
|
||||
const isOpponentMove = (userColor === 'white' && !isWhiteMove) ||
|
||||
(userColor === 'black' && isWhiteMove);
|
||||
|
||||
if (!isOpponentMove) {
|
||||
// This is the user's move, not the opponent's
|
||||
return null;
|
||||
}
|
||||
|
||||
return moves[currentMoveIndex];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if it's the opponent's turn to move
|
||||
*
|
||||
* @param opening - Opening metadata
|
||||
* @param currentMoveIndex - Current position in the move sequence (0-based)
|
||||
* @returns True if it's the opponent's turn
|
||||
*/
|
||||
export function isOpponentTurn(
|
||||
opening: OpeningMetadata,
|
||||
currentMoveIndex: number
|
||||
): boolean {
|
||||
const userColor = getUserColor(opening);
|
||||
const isWhiteMove = currentMoveIndex % 2 === 0;
|
||||
|
||||
return (userColor === 'white' && !isWhiteMove) ||
|
||||
(userColor === 'black' && isWhiteMove);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
TrainingSession,
|
||||
PersistedTrainingSession,
|
||||
} from '@/types/openingTraining';
|
||||
import { SESSION_EXPIRY_DAYS } from './constants';
|
||||
|
||||
/**
|
||||
* Session Manager for Opening Training
|
||||
* Handles creation, persistence, and recovery of training sessions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create a new training session
|
||||
*/
|
||||
export function createSession(
|
||||
openingId: string,
|
||||
openingName: string,
|
||||
initialFEN: string,
|
||||
initialEvaluation: number = 0
|
||||
): TrainingSession {
|
||||
const now = Date.now();
|
||||
|
||||
return {
|
||||
sessionId: uuidv4(),
|
||||
openingId,
|
||||
openingName,
|
||||
startedAt: now,
|
||||
lastUpdatedAt: now,
|
||||
status: 'active',
|
||||
currentFEN: initialFEN,
|
||||
currentMoveIndex: 0,
|
||||
moveHistory: [],
|
||||
deviationMoveIndex: null,
|
||||
initialEvaluation,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Save training session to localStorage
|
||||
*/
|
||||
export function saveSession(session: TrainingSession): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const key = `openingTraining_session_${session.openingId}`;
|
||||
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(session));
|
||||
} catch (error) {
|
||||
console.error('Failed to save training session:', error);
|
||||
// If quota exceeded, try clearing old sessions
|
||||
cleanupStaleSessions();
|
||||
// Retry save
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(session));
|
||||
} catch (retryError) {
|
||||
console.error('Failed to save session after cleanup:', retryError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load training session from localStorage
|
||||
* Returns null if session doesn't exist or has expired
|
||||
*/
|
||||
export function loadSession(openingId: string): TrainingSession | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
const key = `openingTraining_session_${openingId}`;
|
||||
|
||||
try {
|
||||
const data = localStorage.getItem(key);
|
||||
if (!data) return null;
|
||||
|
||||
const session = JSON.parse(data) as TrainingSession;
|
||||
|
||||
// Check expiration
|
||||
const daysSinceUpdate =
|
||||
(Date.now() - session.lastUpdatedAt) / (1000 * 60 * 60 * 24);
|
||||
|
||||
if (daysSinceUpdate > SESSION_EXPIRY_DAYS) {
|
||||
// Session expired - remove it
|
||||
localStorage.removeItem(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return session;
|
||||
} catch (error) {
|
||||
console.error('Failed to load training session:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete training session from localStorage
|
||||
*/
|
||||
export function deleteSession(openingId: string): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const key = `openingTraining_session_${openingId}`;
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update session's last updated timestamp and save
|
||||
*/
|
||||
export function updateSession(session: TrainingSession): void {
|
||||
session.lastUpdatedAt = Date.now();
|
||||
saveSession(session);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup stale sessions (older than SESSION_EXPIRY_DAYS)
|
||||
* Called when localStorage quota is exceeded
|
||||
*/
|
||||
export function cleanupStaleSessions(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const now = Date.now();
|
||||
const keys: string[] = [];
|
||||
|
||||
// Collect all openingTraining session keys
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key?.startsWith('openingTraining_session_')) {
|
||||
keys.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Check each session and remove if expired
|
||||
keys.forEach((key) => {
|
||||
try {
|
||||
const data = localStorage.getItem(key);
|
||||
if (!data) return;
|
||||
|
||||
const session = JSON.parse(data) as TrainingSession;
|
||||
const daysSinceUpdate =
|
||||
(now - session.lastUpdatedAt) / (1000 * 60 * 60 * 24);
|
||||
|
||||
if (daysSinceUpdate > SESSION_EXPIRY_DAYS) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
} catch (error) {
|
||||
// If parsing fails, remove the corrupted entry
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active training sessions
|
||||
* Useful for displaying a list of in-progress trainings
|
||||
*/
|
||||
export function getAllActiveSessions(): TrainingSession[] {
|
||||
if (typeof window === 'undefined') return [];
|
||||
|
||||
const sessions: TrainingSession[] = [];
|
||||
const now = Date.now();
|
||||
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (!key?.startsWith('openingTraining_session_')) continue;
|
||||
|
||||
try {
|
||||
const data = localStorage.getItem(key);
|
||||
if (!data) continue;
|
||||
|
||||
const session = JSON.parse(data) as TrainingSession;
|
||||
|
||||
// Only include non-expired sessions
|
||||
const daysSinceUpdate =
|
||||
(now - session.lastUpdatedAt) / (1000 * 60 * 60 * 24);
|
||||
|
||||
if (daysSinceUpdate <= SESSION_EXPIRY_DAYS && session.status === 'active') {
|
||||
sessions.push(session);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading session:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return sessions;
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { WikipediaSummary } from '@/types/openingTraining';
|
||||
|
||||
const WIKI_CACHE_KEY_PREFIX = 'wiki_summary_';
|
||||
|
||||
/**
|
||||
* Fetches Wikipedia summary for an opening
|
||||
* Priority: 1. Direct slug from database, 2. Local cache by name, 3. localStorage cache, 4. API
|
||||
*/
|
||||
export async function getWikipediaSummary(
|
||||
openingName: string,
|
||||
wikipediaSlug?: string
|
||||
): Promise<WikipediaSummary | null> {
|
||||
try {
|
||||
// If we have a slug from the database, use it directly (most reliable)
|
||||
if (wikipediaSlug) {
|
||||
const slugCached = await getLocalWikipediaCacheBySlug(wikipediaSlug);
|
||||
if (slugCached) {
|
||||
console.log('[Wikipedia] Using local cache (direct slug):', wikipediaSlug);
|
||||
return slugCached;
|
||||
}
|
||||
}
|
||||
|
||||
// Try local cached Wikipedia files by name
|
||||
const localCached = await getLocalWikipediaCache(openingName);
|
||||
if (localCached) {
|
||||
console.log('[Wikipedia] Using local cache (by name):', openingName);
|
||||
return localCached;
|
||||
}
|
||||
|
||||
// Check localStorage cache
|
||||
const cached = getCachedSummary(openingName);
|
||||
if (cached && !isCacheExpired(cached)) {
|
||||
console.log('[Wikipedia] Using localStorage cache for:', openingName);
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Cache miss - fetch from API
|
||||
console.log('[Wikipedia] Fetching from API for:', openingName);
|
||||
const response = await fetch(
|
||||
`/api/v1/wikipedia/summary?opening=${encodeURIComponent(openingName)}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
// Remove stale cache entry on 404
|
||||
if (response.status === 404) {
|
||||
removeCachedSummary(openingName);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const summary: WikipediaSummary = await response.json();
|
||||
|
||||
// Cache the fresh result
|
||||
cacheSummary(openingName, summary);
|
||||
|
||||
return summary;
|
||||
} catch (error) {
|
||||
console.error('Wikipedia service error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load Wikipedia content from local cache by slug (most reliable)
|
||||
*/
|
||||
async function getLocalWikipediaCacheBySlug(
|
||||
slug: string
|
||||
): Promise<WikipediaSummary | null> {
|
||||
try {
|
||||
const cacheUrl = `/wikipedia/${slug}.json`;
|
||||
|
||||
const response = await fetch(cacheUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Convert local cache format to WikipediaSummary format
|
||||
const extract = data.sections
|
||||
.map((s: any) => {
|
||||
if (s.title === 'Introduction') {
|
||||
return s.text;
|
||||
}
|
||||
return `${s.title}\n\n${s.text}`;
|
||||
})
|
||||
.join('\n\n');
|
||||
|
||||
return {
|
||||
openingName: data.openingFamily,
|
||||
title: data.title.replace(/<[^>]*>/g, ''), // Strip HTML tags
|
||||
extract: extract.substring(0, 2000), // Limit size for UI
|
||||
url: data.url,
|
||||
fetchedAt: data.fetchedAt,
|
||||
expiresAt: data.fetchedAt + 365 * 24 * 60 * 60 * 1000, // 1 year for local cache
|
||||
};
|
||||
} catch (error) {
|
||||
// File not found or error reading - not a problem, fallback to other methods
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to load Wikipedia content from local cached files by name (fallback)
|
||||
*/
|
||||
async function getLocalWikipediaCache(
|
||||
openingName: string
|
||||
): Promise<WikipediaSummary | null> {
|
||||
// Convert opening name to slug
|
||||
const slug = openingName.toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
||||
return await getLocalWikipediaCacheBySlug(slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves cached Wikipedia summary from localStorage
|
||||
*/
|
||||
function getCachedSummary(openingName: string): WikipediaSummary | null {
|
||||
try {
|
||||
const key = getCacheKey(openingName);
|
||||
const cached = localStorage.getItem(key);
|
||||
|
||||
if (!cached) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.parse(cached) as WikipediaSummary;
|
||||
} catch (error) {
|
||||
console.error('Error reading Wikipedia cache:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores Wikipedia summary in localStorage cache
|
||||
*/
|
||||
function cacheSummary(openingName: string, summary: WikipediaSummary): void {
|
||||
try {
|
||||
const key = getCacheKey(openingName);
|
||||
localStorage.setItem(key, JSON.stringify(summary));
|
||||
} catch (error) {
|
||||
console.error('Error caching Wikipedia summary:', error);
|
||||
// Cache failure should not break the feature
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes cached Wikipedia summary from localStorage
|
||||
*/
|
||||
function removeCachedSummary(openingName: string): void {
|
||||
try {
|
||||
const key = getCacheKey(openingName);
|
||||
localStorage.removeItem(key);
|
||||
} catch (error) {
|
||||
console.error('Error removing Wikipedia cache:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if cached summary has expired (30 days)
|
||||
*/
|
||||
function isCacheExpired(summary: WikipediaSummary): boolean {
|
||||
const now = Date.now();
|
||||
return now > summary.expiresAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates cache key for opening name
|
||||
*/
|
||||
function getCacheKey(openingName: string): string {
|
||||
return `${WIKI_CACHE_KEY_PREFIX}${openingName.toLowerCase().replace(/\s+/g, '_')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up all expired Wikipedia cache entries
|
||||
* Should be called periodically (e.g., on app load)
|
||||
*/
|
||||
export function cleanupExpiredWikipediaCache(): void {
|
||||
try {
|
||||
const keys = Object.keys(localStorage);
|
||||
const wikiKeys = keys.filter((key) => key.startsWith(WIKI_CACHE_KEY_PREFIX));
|
||||
|
||||
wikiKeys.forEach((key) => {
|
||||
try {
|
||||
const cached = localStorage.getItem(key);
|
||||
if (cached) {
|
||||
const summary: WikipediaSummary = JSON.parse(cached);
|
||||
if (isCacheExpired(summary)) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Remove corrupted cache entries
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error cleaning up Wikipedia cache:', error);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export interface OpeningMetadata {
|
||||
moves: string;
|
||||
name: string;
|
||||
isEcoRoot?: boolean;
|
||||
wikipediaSlug?: string;
|
||||
aliases?: { [key: string]: string };
|
||||
meta?: {
|
||||
strengths_white?: string[];
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Server-side prompt templates for the opening tutor LLM
|
||||
* These templates ensure consistent, high-quality explanations
|
||||
*/
|
||||
|
||||
export const OPENING_TUTOR_SYSTEM_PROMPT = `You are an expert chess opening tutor with deep knowledge of opening theory, strategic ideas, and engine evaluation.
|
||||
|
||||
Your role is to help students understand chess openings through:
|
||||
1. Clear explanations of strategic ideas and plans
|
||||
2. Accurate references to engine evaluations
|
||||
3. Encouraging, educational feedback
|
||||
|
||||
CRITICAL CONSTRAINTS:
|
||||
- NEVER contradict engine evaluations when discussing move quality
|
||||
- When a move is weak (engine shows significant loss), clearly state this based on the evaluation
|
||||
- When a move is strong, reference the engine's positive assessment
|
||||
- Focus on teaching STRATEGIC IDEAS, not just move memorization
|
||||
- Keep explanations concise and accessible
|
||||
- Be encouraging, especially when students make mistakes
|
||||
|
||||
EVALUATION INTERPRETATION:
|
||||
- Centipawn (cp) scores: +100 = 1 pawn advantage for White, -100 = 1 pawn advantage for Black
|
||||
- Mate scores: #N means mate in N moves
|
||||
- Evaluation changes of ±50cp (±0.5 pawns) are significant
|
||||
- Always interpret evaluations from the perspective of the position, not just numbers
|
||||
|
||||
EXPLANATION STYLE:
|
||||
- Use natural, conversational language
|
||||
- Explain WHY moves are good/bad, not just THAT they are
|
||||
- Connect moves to strategic themes (development, control, pawn structure, king safety, etc.)
|
||||
- When students deviate from theory, explain what the repertoire move aims for
|
||||
- Acknowledge good tries even when moves aren't optimal`;
|
||||
|
||||
export const OPENING_TUTOR_TEMPERATURE = 0.7; // Balanced between consistency and natural language
|
||||
|
||||
export const OPENING_TUTOR_MAX_TOKENS = 150; // ~60-80 words for concise responses
|
||||
|
||||
/**
|
||||
* Fallback explanation templates for when LLM is unavailable
|
||||
*/
|
||||
export const FALLBACK_EXPLANATIONS = {
|
||||
'in-theory': (moveSan: string) =>
|
||||
`${moveSan} is part of the opening repertoire. This move follows established theory for this position.`,
|
||||
|
||||
playable: (moveSan: string, theoreticalMoves: string[]) =>
|
||||
`${moveSan} is playable but not in the repertoire. ${
|
||||
theoreticalMoves.length > 0
|
||||
? `The repertoire suggests ${theoreticalMoves.join(' or ')}.`
|
||||
: ''
|
||||
}`,
|
||||
|
||||
weak: (moveSan: string, evalChange: string, bestMove: string) =>
|
||||
`${moveSan} loses significant advantage (${evalChange}). The engine prefers ${bestMove}.`,
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates a fallback explanation when LLM is unavailable
|
||||
*/
|
||||
export function generateFallbackExplanation(
|
||||
category: 'in-theory' | 'playable' | 'weak',
|
||||
moveSan: string,
|
||||
theoreticalMoves: string[] = [],
|
||||
evalChange?: string,
|
||||
bestMove?: string
|
||||
): string {
|
||||
switch (category) {
|
||||
case 'in-theory':
|
||||
return FALLBACK_EXPLANATIONS['in-theory'](moveSan);
|
||||
case 'playable':
|
||||
return FALLBACK_EXPLANATIONS.playable(moveSan, theoreticalMoves);
|
||||
case 'weak':
|
||||
return FALLBACK_EXPLANATIONS.weak(
|
||||
moveSan,
|
||||
evalChange || '',
|
||||
bestMove || 'another move'
|
||||
);
|
||||
default:
|
||||
return `Move played: ${moveSan}`;
|
||||
}
|
||||
}
|
||||
@@ -771,8 +771,8 @@ export function generateTacticExercise(params: TacticExerciseParams): TacticExer
|
||||
},
|
||||
resultPosition: { fen: chosen.resultingFen },
|
||||
pattern: chosen.expectedPattern as TacticalPattern,
|
||||
moves: chosen.moves, // Include full move sequence
|
||||
rating: chosen.rating, // Include puzzle rating
|
||||
...(('moves' in chosen) && { moves: chosen.moves }), // Include full move sequence if available
|
||||
...(('rating' in chosen) && { rating: chosen.rating }), // Include puzzle rating if available
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { StockfishEvaluation } from '@/lib/stockfish';
|
||||
|
||||
/**
|
||||
* Type definitions for the Interactive Chess Opening Training feature
|
||||
*/
|
||||
|
||||
// Move categorization types
|
||||
export type MoveCategory = 'in-theory' | 'playable' | 'weak';
|
||||
|
||||
// Training session status
|
||||
export type TrainingSessionStatus = 'active' | 'completed' | 'abandoned';
|
||||
|
||||
/**
|
||||
* Represents an active or completed opening training session
|
||||
*/
|
||||
export interface TrainingSession {
|
||||
sessionId: string;
|
||||
openingId: string;
|
||||
openingName: string;
|
||||
startedAt: number; // Unix timestamp (milliseconds)
|
||||
lastUpdatedAt: number; // Unix timestamp (milliseconds)
|
||||
status: TrainingSessionStatus;
|
||||
currentFEN: string;
|
||||
currentMoveIndex: number;
|
||||
moveHistory: MoveHistoryEntry[];
|
||||
deviationMoveIndex: number | null; // Index where user first left theory
|
||||
initialEvaluation: number; // Starting position evaluation (centipawns)
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a single move in the training session
|
||||
*/
|
||||
export interface MoveHistoryEntry {
|
||||
moveNumber: number; // Full move number (1, 2, 3, ...)
|
||||
color: 'white' | 'black';
|
||||
san: string; // Standard Algebraic Notation (e.g., "Nf3", "exd5")
|
||||
uci: string; // UCI notation (e.g., "e2e4", "e7e5")
|
||||
fen: string; // Position AFTER this move
|
||||
evaluation: StockfishEvaluation;
|
||||
classification: MoveFeedbackClassification;
|
||||
timestamp: number; // Unix timestamp when move was played
|
||||
}
|
||||
|
||||
/**
|
||||
* Classification of a user's move relative to opening theory and objective strength
|
||||
*/
|
||||
export interface MoveFeedbackClassification {
|
||||
category: MoveCategory;
|
||||
inRepertoire: boolean; // Whether move matches expected repertoire line
|
||||
evaluationChange: number; // Centipawn change from previous position
|
||||
isSignificantSwing: boolean; // Whether eval change exceeds threshold (±50cp)
|
||||
theoreticalAlternatives: string[]; // List of in-theory moves (empty if only one)
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete feedback package for a user's move
|
||||
*/
|
||||
export interface MoveFeedback {
|
||||
move: MoveHistoryEntry;
|
||||
classification: MoveFeedbackClassification;
|
||||
evaluation: StockfishEvaluation;
|
||||
previousEvaluation: StockfishEvaluation;
|
||||
llmExplanation: string; // Natural language explanation from tutor
|
||||
generatedAt: number; // Unix timestamp
|
||||
}
|
||||
|
||||
/**
|
||||
* Wikipedia summary for an opening
|
||||
*/
|
||||
export interface WikipediaSummary {
|
||||
openingName: string; // Opening name used for lookup
|
||||
title: string; // Wikipedia article title
|
||||
extract: string; // Plain text summary (2-3 paragraphs)
|
||||
url: string; // Full Wikipedia article URL
|
||||
fetchedAt: number; // Unix timestamp when fetched
|
||||
expiresAt: number; // Unix timestamp for cache expiration
|
||||
}
|
||||
|
||||
/**
|
||||
* Persisted training session (localStorage)
|
||||
* Only active sessions are persisted
|
||||
*/
|
||||
export type PersistedTrainingSession = Omit<TrainingSession, 'status'> & {
|
||||
status: 'active';
|
||||
};
|
||||
Reference in New Issue
Block a user