playwright

This commit is contained in:
Stefan
2025-12-06 17:37:44 +01:00
parent 743bda02bf
commit 9ebca615c7
17 changed files with 32333 additions and 193 deletions
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
+4378 -15
View File
File diff suppressed because it is too large Load Diff
+4476 -16
View File
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
+64 -20
View File
@@ -22,6 +22,7 @@
"tailwind-merge": "^3.4.0" "tailwind-merge": "^3.4.0"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.57.0",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@testing-library/dom": "^10.4.1", "@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
@@ -112,7 +113,6 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5", "@babel/generator": "^7.28.5",
@@ -700,7 +700,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
}, },
@@ -724,7 +723,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -746,7 +744,6 @@
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
@@ -2306,6 +2303,22 @@
"url": "https://opencollective.com/pkgr" "url": "https://opencollective.com/pkgr"
} }
}, },
"node_modules/@playwright/test": {
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz",
"integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.57.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@rtsao/scc": { "node_modules/@rtsao/scc": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -2949,7 +2962,6 @@
"integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==", "integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
@@ -2959,7 +2971,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.6.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.6.tgz",
"integrity": "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w==", "integrity": "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
@@ -2970,7 +2981,6 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
@@ -3058,7 +3068,6 @@
"integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==", "integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.47.0", "@typescript-eslint/scope-manager": "8.47.0",
"@typescript-eslint/types": "8.47.0", "@typescript-eslint/types": "8.47.0",
@@ -3595,7 +3604,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -4116,7 +4124,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.8.25", "baseline-browser-mapping": "^2.8.25",
"caniuse-lite": "^1.0.30001754", "caniuse-lite": "^1.0.30001754",
@@ -5095,7 +5102,6 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@@ -5281,7 +5287,6 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@rtsao/scc": "^1.1.0", "@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9", "array-includes": "^3.1.9",
@@ -7092,7 +7097,6 @@
"integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@jest/core": "30.2.0", "@jest/core": "30.2.0",
"@jest/types": "30.2.0", "@jest/types": "30.2.0",
@@ -8079,7 +8083,6 @@
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"cssstyle": "^4.2.1", "cssstyle": "^4.2.1",
"data-urls": "^5.0.0", "data-urls": "^5.0.0",
@@ -9974,6 +9977,53 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/playwright": {
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz",
"integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.57.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz",
"integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==",
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/possible-typed-array-names": { "node_modules/possible-typed-array-names": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -10133,7 +10183,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -10161,7 +10210,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
@@ -11323,7 +11371,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -11515,7 +11562,6 @@
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@cspotcode/source-map-support": "^0.8.0", "@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7", "@tsconfig/node10": "^1.0.7",
@@ -11706,7 +11752,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -12439,7 +12484,6 @@
"integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }
+6 -1
View File
@@ -8,7 +8,11 @@
"build": "next build", "build": "next build",
"start": "next start -p 3050", "start": "next start -p 3050",
"lint": "eslint", "lint": "eslint",
"test": "jest" "test": "jest",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:headed": "playwright test --headed",
"test:all": "npm test && npm run test:e2e"
}, },
"dependencies": { "dependencies": {
"@google/generative-ai": "^0.24.1", "@google/generative-ai": "^0.24.1",
@@ -24,6 +28,7 @@
"tailwind-merge": "^3.4.0" "tailwind-merge": "^3.4.0"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.57.0",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@testing-library/dom": "^10.4.1", "@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
+30
View File
@@ -0,0 +1,30 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3050',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3050',
reuseExistingServer: !process.env.CI,
timeout: 120 * 1000,
},
});
+159
View File
@@ -0,0 +1,159 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { ArrowLeft, Target, BookOpen } from "lucide-react";
import Header from "@/components/Header";
import { useTranslation } from "@/lib/i18n/useTranslation";
import { SupportedLanguage } from "@/lib/i18n/translations";
import { Personality, PERSONALITIES } from "@/lib/personalities";
const TACTICAL_PATTERNS = [
{ id: 'PIN', icon: '📌' },
{ id: 'SKEWER', icon: '🎯' },
{ id: 'FORK', icon: '🍴' },
{ id: 'DISCOVERED_CHECK', icon: '🔍' },
{ id: 'DOUBLE_ATTACK', icon: '⚔️' },
{ id: 'OVERLOADING', icon: '⚖️' },
{ id: 'BACK_RANK_WEAKNESS', icon: '🏰' },
{ id: 'TRAPPED_PIECE', icon: '🪤' },
] as const;
export default function LearningAreaPage() {
const router = useRouter();
const [language, setLanguage] = useState<SupportedLanguage>('en');
const [mounted, setMounted] = useState(false);
const [selectedPersonality, setSelectedPersonality] = useState<Personality>(PERSONALITIES[0]);
useEffect(() => {
const storedLang = localStorage.getItem("chess_tutor_language");
if (storedLang) setLanguage(storedLang as SupportedLanguage);
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);
if (!mounted) return null;
const getPatternName = (patternId: string): string => {
const key = patternId.toLowerCase().replace(/_/g, '') as keyof typeof t.learning.patterns;
// Map pattern IDs to translation keys
const mapping: Record<string, keyof typeof t.learning.patterns> = {
'PIN': 'pin',
'SKEWER': 'skewer',
'FORK': 'fork',
'DISCOVERED_CHECK': 'discoveredCheck',
'DOUBLE_ATTACK': 'doubleAttack',
'OVERLOADING': 'overloading',
'BACK_RANK_WEAKNESS': 'backRankWeakness',
'TRAPPED_PIECE': 'trappedPiece',
};
return t.learning.patterns[mapping[patternId]];
};
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("/")}
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>
<h1 className="text-4xl font-bold mb-4 text-gray-800 dark:text-white">
{t.learning.title}
</h1>
<p className="text-lg text-gray-600 dark:text-gray-400 mb-8">
{t.learning.subtitle}
</p>
{/* Coach Selection */}
<div className="mb-12 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-4">
{t.analysis.chooseCoach}
</label>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{PERSONALITIES.map((personality) => (
<button
key={personality.id}
onClick={() => {
setSelectedPersonality(personality);
localStorage.setItem("chess_tutor_personality", personality.id);
}}
className={`p-4 rounded-lg border-2 transition-all ${
selectedPersonality.id === personality.id
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-blue-300 dark:hover:border-blue-700'
}`}
>
<div className="text-2xl mb-1">{personality.image}</div>
<div className="text-sm font-medium text-gray-900 dark:text-white">
{personality.name}
</div>
</button>
))}
</div>
</div>
{/* Tactical Patterns Section */}
<div className="mb-12">
<div className="flex items-center gap-3 mb-6">
<Target className="text-blue-600 dark:text-blue-400" size={32} />
<h2 className="text-2xl font-bold text-gray-800 dark:text-white">
{t.learning.tacticalPatterns}
</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{TACTICAL_PATTERNS.map((pattern) => (
<button
key={pattern.id}
onClick={() => router.push(`/learning/tactics/${pattern.id.toLowerCase()}`)}
className="group bg-white dark:bg-gray-800 p-6 rounded-xl hover:bg-blue-50 dark:hover:bg-gray-700 transition-all border-2 border-transparent hover:border-blue-500 dark:hover:border-blue-400 shadow-sm hover:shadow-md text-left"
>
<div className="text-4xl mb-3 group-hover:scale-110 transition-transform">
{pattern.icon}
</div>
<h3 className="font-bold text-gray-900 dark:text-white text-lg">
{getPatternName(pattern.id)}
</h3>
</button>
))}
</div>
</div>
{/* Openings Section */}
<div>
<div className="flex items-center gap-3 mb-6">
<BookOpen className="text-purple-600 dark:text-purple-400" size={32} />
<h2 className="text-2xl font-bold text-gray-800 dark:text-white">
{t.learning.openings}
</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>
</div>
</div>
</div>
</>
);
}
+680
View File
@@ -0,0 +1,680 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useRouter, useParams } from "next/navigation";
import { Chess, Move } from "chess.js";
import { Chessboard } from "react-chessboard";
import { ArrowLeft, CheckCircle, XCircle, RefreshCw, SkipForward } from "lucide-react";
import Header from "@/components/Header";
import { Tutor } from "@/components/Tutor";
import { useTranslation } from "@/lib/i18n/useTranslation";
import { SupportedLanguage } from "@/lib/i18n/translations";
import { Personality, PERSONALITIES } from "@/lib/personalities";
import {
generateTacticExercise,
TacticalPatternType,
TacticExercise
} from "@/lib/tacticalLibrary";
import pinFixtures from "../../../../../fixtures/tactics/pin.json";
type FeedbackState = 'none' | 'correct' | 'incorrect';
export default function TacticalPracticePage() {
const router = useRouter();
const params = useParams();
const pattern = (params.pattern as string).toUpperCase() as TacticalPatternType;
const [language, setLanguage] = useState<SupportedLanguage>('en');
const [mounted, setMounted] = useState(false);
const [exercise, setExercise] = useState<TacticExercise | null>(null);
const [fen, setFen] = useState<string>('');
const [feedback, setFeedback] = useState<FeedbackState>('none');
const [selectedPersonality, setSelectedPersonality] = useState<Personality>(PERSONALITIES[0]);
const [apiKey, setApiKey] = useState<string>('');
const [userMove, setUserMove] = useState<Move | null>(null);
const [setupError, setSetupError] = useState<string | null>(null);
const [showSetupWarning, setShowSetupWarning] = useState<boolean>(false);
const [difficulty, setDifficulty] = useState<'easy' | 'medium' | 'hard'>('easy');
const [currentMoveIndex, setCurrentMoveIndex] = useState<number>(0); // Track progress in move sequence
// Statistics tracking
const [stats, setStats] = useState({
totalCorrect: 0,
totalIncorrect: 0,
currentStreak: 0,
bestStreak: 0,
});
const gameRef = useRef<Chess>(new Chess());
// Sound Refs
const moveSound = useRef<HTMLAudioElement | null>(null);
const captureSound = useRef<HTMLAudioElement | null>(null);
const successSound = useRef<HTMLAudioElement | null>(null);
const errorSound = useRef<HTMLAudioElement | null>(null);
// Initialize sound effects
useEffect(() => {
moveSound.current = new Audio('/sounds/move.wav');
captureSound.current = new Audio('/sounds/capture.wav');
successSound.current = new Audio('/sounds/victory.wav');
errorSound.current = new Audio('/sounds/defeat.wav');
}, []);
const playMoveSound = (captured: boolean) => {
if (captured) {
captureSound.current?.play().catch(e => console.error("Audio play failed", e));
} else {
moveSound.current?.play().catch(e => console.error("Audio play failed", e));
}
};
useEffect(() => {
const storedLang = localStorage.getItem("chess_tutor_language");
if (storedLang) setLanguage(storedLang as SupportedLanguage);
// Load API key from the correct localStorage key
const storedApiKey = localStorage.getItem("gemini_api_key");
if (storedApiKey) setApiKey(storedApiKey);
const storedPersonalityId = localStorage.getItem("chess_tutor_personality");
if (storedPersonalityId) {
const personality = PERSONALITIES.find(p => p.id === storedPersonalityId);
if (personality) setSelectedPersonality(personality);
}
// Check if high-quality puzzles have been set up by checking the fixture metadata
// Lichess puzzles have source: "https://database.lichess.org/"
const isLichessPuzzles = pinFixtures.source === "https://database.lichess.org/";
const warningDismissed = localStorage.getItem("tactical_puzzles_warning_dismissed");
if (!isLichessPuzzles && !warningDismissed) {
setShowSetupWarning(true);
}
setMounted(true);
}, []);
useEffect(() => {
if (mounted) {
loadNewExercise();
}
}, [mounted, pattern]);
const loadNewExercise = () => {
try {
// Try to load a puzzle for either side (white or black)
// The library will randomly pick from available puzzles
const randomSide = Math.random() > 0.5 ? 'white' : 'black';
const newExercise = generateTacticExercise({
patternType: pattern,
side: randomSide,
difficulty: difficulty, // Use selected difficulty
});
console.log('📚 Loaded new exercise:');
console.log(' FEN:', newExercise.startPosition.fen);
console.log(' Solution move:', newExercise.solutionMove);
console.log(' Move sequence:', newExercise.moves);
console.log(' Rating:', newExercise.rating);
setExercise(newExercise);
setFen(newExercise.startPosition.fen);
gameRef.current = new Chess(newExercise.startPosition.fen);
setFeedback('none');
setCurrentMoveIndex(0); // Reset move sequence progress
setSetupError(null);
} catch (error) {
console.error('Error loading exercise:', error);
// Check if error is due to missing fixtures
if (error instanceof Error && error.message.includes('No fixture available')) {
setSetupError(`No ${difficulty} puzzles available for this pattern. Try a different difficulty.`);
} else {
setSetupError('Failed to load tactical exercise. Please try again.');
}
}
};
const t = useTranslation(language);
// Determine which side is to move from the current position
const getSideToMove = (): 'white' | 'black' => {
if (!exercise) return 'white';
const chess = new Chess(exercise.startPosition.fen);
return chess.turn() === 'w' ? 'white' : 'black';
};
const sideToMove = getSideToMove();
if (!mounted) return null;
// Show setup error if puzzles are not configured
if (setupError) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<Header language={language} onLanguageChange={setLanguage} />
<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">
<div className="flex items-start">
<XCircle className="w-6 h-6 text-red-600 dark:text-red-400 mt-0.5 mr-3 flex-shrink-0" />
<div className="flex-1">
<h3 className="text-lg font-bold text-red-900 dark:text-red-100 mb-2">
Setup Required
</h3>
<p className="text-red-800 dark:text-red-200 mb-4">
{setupError}
</p>
<div className="bg-gray-900 dark:bg-gray-950 rounded-lg p-4 mb-4">
<p className="text-sm text-gray-300 mb-2 font-mono">
Run this command in your terminal:
</p>
<code className="text-green-400 font-mono text-sm">
cd chess_tutor && python3 scripts/setup_tactical_puzzles.py
</code>
</div>
<p className="text-sm text-red-700 dark:text-red-300">
This one-time setup will download high-quality tactical puzzles from Lichess.
It may take 5-10 minutes depending on your internet connection.
</p>
<div className="mt-4 flex gap-3">
<button
onClick={() => router.push('/learning')}
className="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors"
>
Back to Learning Area
</button>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<RefreshCw className="w-4 h-4 inline mr-2" />
Retry
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
if (!exercise) return null;
const getPatternName = (): string => {
const mapping: Record<string, keyof typeof t.learning.patterns> = {
'PIN': 'pin',
'SKEWER': 'skewer',
'FORK': 'fork',
'DISCOVERED_CHECK': 'discoveredCheck',
'DOUBLE_ATTACK': 'doubleAttack',
'OVERLOADING': 'overloading',
'BACK_RANK_WEAKNESS': 'backRankWeakness',
'TRAPPED_PIECE': 'trappedPiece',
};
return t.learning.patterns[mapping[pattern]];
};
const onDrop = ({ sourceSquare, targetSquare }: { sourceSquare: string; targetSquare: string | null }) => {
if (!targetSquare || feedback !== 'none') return false;
// Additional validation: Check if there's actually a piece on the source square
const piece = gameRef.current.get(sourceSquare);
if (!piece) {
console.warn('⚠️ No piece found on source square:', sourceSquare);
return false;
}
console.log('🎯 Attempting move:', {
from: sourceSquare,
to: targetSquare,
piece: piece.type,
color: piece.color
});
const move = {
from: sourceSquare,
to: targetSquare,
promotion: "q",
};
try {
const result = gameRef.current.move(move);
if (!result) {
console.warn('❌ Invalid move attempted:', move);
return false;
}
// Track the user's move for the Tutor (store the full Move object)
setUserMove(result);
// Check if this is the correct move in the sequence
const moves = exercise.moves || [];
// If no move sequence (old format), fall back to single-move check
if (moves.length === 0) {
const isCorrect =
result.from === exercise.solutionMove.from &&
result.to === exercise.solutionMove.to;
if (isCorrect) {
playMoveSound(!!result.captured);
successSound.current?.play().catch(e => console.error("Audio play failed", e));
setFeedback('correct');
setFen(gameRef.current.fen());
// Update stats for correct answer
setStats(prev => ({
totalCorrect: prev.totalCorrect + 1,
totalIncorrect: prev.totalIncorrect,
currentStreak: prev.currentStreak + 1,
bestStreak: Math.max(prev.bestStreak, prev.currentStreak + 1),
}));
} else {
errorSound.current?.play().catch(e => console.error("Audio play failed", e));
setFeedback('incorrect');
gameRef.current = new Chess(exercise.startPosition.fen);
// Update stats for incorrect answer
setStats(prev => ({
totalCorrect: prev.totalCorrect,
totalIncorrect: prev.totalIncorrect + 1,
currentStreak: 0,
bestStreak: prev.bestStreak,
}));
}
return true;
}
// Multi-move sequence handling
const expectedMove = moves[currentMoveIndex];
if (!expectedMove || !expectedMove.player) {
// This shouldn't happen - we should only be waiting for player moves
console.error('Unexpected state: waiting for player move but none expected');
return false;
}
// Check if the move matches (compare UCI notation)
const playerMoveUci = result.from + result.to + (result.promotion || '');
const isCorrect = playerMoveUci === expectedMove.uci;
// Debug logging
console.log('🔍 Move Validation Debug:');
console.log(' Player move UCI:', playerMoveUci);
console.log(' Expected move UCI:', expectedMove.uci);
console.log(' Current move index:', currentMoveIndex);
console.log(' Is correct?', isCorrect);
console.log(' Move result:', result);
if (!isCorrect) {
errorSound.current?.play().catch(e => console.error("Audio play failed", e));
setFeedback('incorrect');
// Reset the board to starting position
gameRef.current = new Chess(exercise.startPosition.fen);
setFen(exercise.startPosition.fen);
setCurrentMoveIndex(0);
// Update stats for incorrect answer
setStats(prev => ({
totalCorrect: prev.totalCorrect,
totalIncorrect: prev.totalIncorrect + 1,
currentStreak: 0,
bestStreak: prev.bestStreak,
}));
return true;
}
// Correct move! Play sound and update the board
playMoveSound(!!result.captured);
setFen(gameRef.current.fen());
// Check if this was the last move in the sequence
if (currentMoveIndex >= moves.length - 1) {
// Puzzle complete!
successSound.current?.play().catch(e => console.error("Audio play failed", e));
setFeedback('correct');
// Update stats for correct answer (only when puzzle is complete)
setStats(prev => ({
totalCorrect: prev.totalCorrect + 1,
totalIncorrect: prev.totalIncorrect,
currentStreak: prev.currentStreak + 1,
bestStreak: Math.max(prev.bestStreak, prev.currentStreak + 1),
}));
return true;
}
// More moves to go - make the opponent's response automatically
const nextMoveIndex = currentMoveIndex + 1;
const opponentMove = moves[nextMoveIndex];
if (opponentMove && !opponentMove.player) {
// Make opponent's move after a short delay
setTimeout(() => {
try {
const oppMove = gameRef.current.move(opponentMove.uci);
if (oppMove) {
// Play sound for opponent's move
playMoveSound(!!oppMove.captured);
setFen(gameRef.current.fen());
setCurrentMoveIndex(nextMoveIndex + 1); // Ready for next player move
}
} catch (e) {
console.error('Failed to make opponent move:', e);
}
}, 500); // 500ms delay so user can see their move
} else {
// No opponent response - puzzle complete
successSound.current?.play().catch(e => console.error("Audio play failed", e));
setFeedback('correct');
}
return true;
} catch (e) {
return false;
}
};
const handleTryAgain = () => {
gameRef.current = new Chess(exercise.startPosition.fen);
setFen(exercise.startPosition.fen);
setFeedback('none');
setCurrentMoveIndex(0); // Reset move sequence progress
// Don't reset userMove - keep the chat context
// The Tutor will know the user tried again because feedback changed to 'none'
};
const handleSkipPuzzle = () => {
// Skip counts as incorrect for stats
setStats(prev => ({
totalCorrect: prev.totalCorrect,
totalIncorrect: prev.totalIncorrect + 1,
currentStreak: 0,
bestStreak: prev.bestStreak,
}));
loadNewExercise();
};
const handleNextExercise = () => {
loadNewExercise();
};
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.practice.backToLearning}</span>
</button>
{/* Difficulty Selector */}
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
Difficulty:
</span>
<div className="flex gap-1 bg-gray-200 dark:bg-gray-700 rounded-lg p-1">
<button
onClick={() => {
setDifficulty('easy');
loadNewExercise();
}}
className={`px-3 py-1 text-sm font-medium rounded transition-colors ${
difficulty === 'easy'
? 'bg-green-500 text-white'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
}`}
>
Easy (800-1400)
</button>
<button
onClick={() => {
setDifficulty('medium');
loadNewExercise();
}}
className={`px-3 py-1 text-sm font-medium rounded transition-colors ${
difficulty === 'medium'
? 'bg-yellow-500 text-white'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
}`}
>
Medium (1400-1800)
</button>
<button
onClick={() => {
setDifficulty('hard');
loadNewExercise();
}}
className={`px-3 py-1 text-sm font-medium rounded transition-colors ${
difficulty === 'hard'
? 'bg-red-500 text-white'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
}`}
>
Hard (1800-2200)
</button>
</div>
</div>
</div>
{/* Setup Warning Banner */}
{showSetupWarning && (
<div className="mb-6 bg-yellow-50 dark:bg-yellow-900/20 border-l-4 border-yellow-400 p-4 rounded-r-lg">
<div className="flex items-start">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3 flex-1">
<p className="text-sm text-yellow-800 dark:text-yellow-200 font-medium">
Using basic tactical puzzles
</p>
<p className="mt-1 text-sm text-yellow-700 dark:text-yellow-300">
For better quality puzzles from Lichess (5.6M verified puzzles), run:
</p>
<div className="mt-2 bg-gray-900 dark:bg-gray-950 rounded px-3 py-2">
<code className="text-xs text-green-400 font-mono">
cd chess_tutor && python3 scripts/setup_tactical_puzzles.py
</code>
</div>
<p className="mt-2 text-xs text-yellow-600 dark:text-yellow-400">
One-time setup (~5-10 minutes). Current puzzles will work but may have quality issues.
</p>
</div>
<button
onClick={() => {
setShowSetupWarning(false);
localStorage.setItem("tactical_puzzles_warning_dismissed", "true");
}}
className="ml-3 flex-shrink-0 text-yellow-600 dark:text-yellow-400 hover:text-yellow-800 dark:hover:text-yellow-200"
>
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</button>
</div>
</div>
)}
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="text-3xl font-bold mb-2 text-gray-800 dark:text-white">
{getPatternName()}
</h1>
<p className="text-lg text-gray-600 dark:text-gray-400">
{t.learning.practice.findTheMove} {getPatternName().toLowerCase()}
</p>
</div>
{/* Puzzle Rating Display */}
{exercise.rating && (
<div className="flex flex-col items-end">
<span className="text-sm text-gray-500 dark:text-gray-400">
Puzzle Rating
</span>
<div className={`text-2xl font-bold ${
exercise.rating < 1400 ? 'text-green-600 dark:text-green-400' :
exercise.rating < 1800 ? 'text-yellow-600 dark:text-yellow-400' :
'text-red-600 dark:text-red-400'
}`}>
{exercise.rating}
</div>
<span className="text-xs text-gray-500 dark:text-gray-400">
{exercise.rating < 1400 ? 'Easy' :
exercise.rating < 1800 ? 'Medium' : 'Hard'}
</span>
</div>
)}
</div>
{/* Statistics Display */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div className="text-sm text-gray-500 dark:text-gray-400">Correct</div>
<div className="text-2xl font-bold text-green-600 dark:text-green-400">{stats.totalCorrect}</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div className="text-sm text-gray-500 dark:text-gray-400">Incorrect</div>
<div className="text-2xl font-bold text-red-600 dark:text-red-400">{stats.totalIncorrect}</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div className="text-sm text-gray-500 dark:text-gray-400">Current Streak</div>
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">{stats.currentStreak}</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div className="text-sm text-gray-500 dark:text-gray-400">Best Streak</div>
<div className="text-2xl font-bold text-purple-600 dark:text-purple-400">{stats.bestStreak}</div>
</div>
</div>
{/* Main Content Grid */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{/* Chessboard */}
<div className="md:col-span-2">
<div className="bg-[#779954] p-[2px] rounded-sm max-w-[600px] mx-auto">
<Chessboard
key={fen}
options={{
position: fen,
onPieceDrop: ({ sourceSquare, targetSquare }) => {
console.log('🎲 onPieceDrop called with:', { sourceSquare, targetSquare });
return onDrop({ sourceSquare, targetSquare });
},
darkSquareStyle: { backgroundColor: '#779954' },
lightSquareStyle: { backgroundColor: '#e9edcc' },
animationDurationInMs: 200,
boardOrientation: sideToMove
}}
/>
</div>
</div>
{/* Coach Chat */}
<div className="md:col-span-1">
{apiKey ? (
<Tutor
game={gameRef.current}
currentFen={fen}
userMove={userMove}
computerMove={null}
stockfish={null}
evalP0={null}
evalP2={null}
openingData={[]}
missedTactics={[]}
onAnalysisComplete={() => {}}
apiKey={apiKey}
personality={selectedPersonality}
language={language}
playerColor={sideToMove}
onCheckComputerMove={() => {}}
resignationContext={null}
tacticalPracticeMode={{
patternName: getPatternName(),
solutionMove: exercise.solutionMove,
feedback: feedback,
moves: exercise.moves || [],
currentMoveIndex: currentMoveIndex,
stats: stats,
}}
/>
) : (
<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={() => router.push('/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>
)}
{/* Action Buttons */}
<div className="mt-4 space-y-3">
{feedback === 'correct' && (
<button
onClick={handleNextExercise}
className="w-full py-3 px-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-semibold shadow-lg transition-all flex items-center justify-center gap-2"
>
<RefreshCw size={18} />
{t.learning.practice.nextExercise}
</button>
)}
{feedback === 'incorrect' && (
<>
<button
onClick={handleTryAgain}
className="w-full py-3 px-4 bg-orange-600 text-white rounded-lg hover:bg-orange-700 font-semibold shadow-lg transition-all flex items-center justify-center gap-2"
>
<RefreshCw size={18} />
{t.learning.practice.tryAgain}
</button>
<button
onClick={handleSkipPuzzle}
className="w-full py-2 px-4 bg-gray-500 text-white rounded-lg hover:bg-gray-600 font-medium transition-all flex items-center justify-center gap-2"
>
<SkipForward size={18} />
Skip Puzzle
</button>
</>
)}
{feedback === 'none' && (
<button
onClick={handleSkipPuzzle}
className="w-full py-2 px-4 bg-gray-500 text-white rounded-lg hover:bg-gray-600 font-medium transition-all flex items-center justify-center gap-2"
>
<SkipForward size={18} />
Skip Puzzle
</button>
)}
</div>
</div>
</div>
</div>
</div>
</>
);
}
+15 -1
View File
@@ -2,7 +2,7 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Settings, ChevronDown, ChevronUp, Brain, Trash2, BarChart2 } from "lucide-react"; import { Settings, ChevronDown, ChevronUp, Brain, Trash2, BarChart2, GraduationCap } from "lucide-react";
import { Personality, PERSONALITIES } from "@/lib/personalities"; import { Personality, PERSONALITIES } from "@/lib/personalities";
import { useTranslation } from "@/lib/i18n/useTranslation"; import { useTranslation } from "@/lib/i18n/useTranslation";
import { SupportedLanguage } from "@/lib/i18n/translations"; import { SupportedLanguage } from "@/lib/i18n/translations";
@@ -350,6 +350,20 @@ export default function StartScreen({ onStartGame, onResumeGame, savedGames, onD
{t.start.analyzeGame} {t.start.analyzeGame}
</button> </button>
</div> </div>
{/* Learning Area */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
<p className="text-sm font-bold text-gray-700 dark:text-gray-300 mb-3 uppercase tracking-wide">
{t.start.learningArea}
</p>
<button
onClick={() => router.push("/learning")}
className="w-full py-4 px-4 bg-teal-600 text-white rounded-xl hover:bg-teal-700 font-semibold shadow-lg transition-transform transform hover:scale-[1.02] flex items-center justify-center gap-2"
>
<GraduationCap size={18} />
{t.start.learningArea}
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
+151 -18
View File
@@ -41,6 +41,19 @@ interface TutorProps {
result: string; result: string;
winner: 'White' | 'Black' | 'Draw'; winner: 'White' | 'Black' | 'Draw';
} | null; } | null;
tacticalPracticeMode?: {
patternName: string;
solutionMove: { from: string; to: string; promotion?: string };
feedback: 'none' | 'correct' | 'incorrect';
moves?: Array<{ uci: string; san: string; player: boolean }>;
currentMoveIndex?: number;
stats?: {
totalCorrect: number;
totalIncorrect: number;
currentStreak: number;
bestStreak: number;
};
};
} }
interface Message { interface Message {
@@ -49,7 +62,7 @@ interface Message {
timestamp: number; timestamp: number;
} }
export function Tutor({ game, currentFen, userMove, computerMove, stockfish, evalP0, evalP2, openingData, missedTactics, onAnalysisComplete, apiKey, personality, language, playerColor, onCheckComputerMove, resignationContext }: TutorProps) { export function Tutor({ game, currentFen, userMove, computerMove, stockfish, evalP0, evalP2, openingData, missedTactics, onAnalysisComplete, apiKey, personality, language, playerColor, onCheckComputerMove, resignationContext, tacticalPracticeMode }: TutorProps) {
const [messages, setMessages] = useState<Message[]>([]); const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -64,16 +77,51 @@ export function Tutor({ game, currentFen, userMove, computerMove, stockfish, eva
const playerColorName = playerColor === 'white' ? 'White' : 'Black'; const playerColorName = playerColor === 'white' ? 'White' : 'Black';
const tutorColorName = tutorColor === 'white' ? 'White' : 'Black'; const tutorColorName = tutorColor === 'white' ? 'White' : 'Black';
// Initialize chat session with Personality System Prompt // Extract stable values from tacticalPracticeMode to avoid recreating chat on feedback changes
const patternName = tacticalPracticeMode?.patternName;
const solutionMoveKey = tacticalPracticeMode ? `${tacticalPracticeMode.solutionMove.from}-${tacticalPracticeMode.solutionMove.to}` : null;
// Track the current puzzle to detect when it changes
const currentPuzzleRef = useRef<string | null>(null);
// Initialize chat session with Personality System Prompt (only once per pattern type)
useEffect(() => { useEffect(() => {
if (apiKey) { if (apiKey) {
const model = getGenAIModel(apiKey, "gemini-2.5-flash"); const model = getGenAIModel(apiKey, "gemini-2.5-flash");
const session = model.startChat({
history: [ // Build system prompt based on mode
{ // NOTE: For tactical practice, we don't include the specific puzzle solution in the system prompt
role: "user", // Instead, we'll send it as a message when the puzzle changes
parts: [{ const systemPrompt = tacticalPracticeMode ? `
text: ` You are a Chess Coach helping a student practice tactical patterns.
You must strictly follow the personality defined below.
PERSONALITY:
${personality.systemPrompt}
YOUR ROLE:
You are coaching the student to recognize and execute the "${tacticalPracticeMode.patternName}" tactical pattern.
YOUR RESPONSIBILITIES:
1. WELCOME: Start with a brief, encouraging welcome about practicing ${tacticalPracticeMode.patternName}.
2. HINTS: When the student asks for a hint, provide helpful guidance WITHOUT giving away the exact move.
- Describe what to look for (e.g., "Look for a piece that can attack two targets at once")
- Point to the general area (e.g., "Pay attention to your knight's possibilities")
- NEVER say the exact move unless explicitly asked
3. FEEDBACK: React to the student's attempts:
- If correct: Celebrate and explain why the move works
- If incorrect: Encourage them to try again and give a subtle hint
4. TEACHING: Explain the tactical pattern in simple terms when appropriate
5. NEW PUZZLE: When you receive a new puzzle, acknowledge it briefly and encourage the student
CRITICAL RULES:
- Be encouraging and supportive
- Keep responses concise (2-3 sentences max)
- Do NOT be repetitive - vary your language
- You MUST respond in the following language: ${language.toUpperCase()}
- Translate your personality style into this language
- When a new puzzle is presented, you will be told the solution move - use this to provide hints and feedback
` : `
You are a Chess Tutor with a unique dual role. You are a Chess Tutor with a unique dual role.
You must strictly follow the personality defined below. You must strictly follow the personality defined below.
Do NOT invent moves or evaluations. Use the provided JSON data. Do NOT invent moves or evaluations. Use the provided JSON data.
@@ -103,27 +151,87 @@ CRITICAL RULES:
- Be concise but engaging. - Be concise but engaging.
- You MUST respond in the following language: ${language.toUpperCase()}. - You MUST respond in the following language: ${language.toUpperCase()}.
- Translate your personality style into this language. - Translate your personality style into this language.
` }] `;
const session = model.startChat({
history: [
{
role: "user",
parts: [{ text: systemPrompt }]
}, },
{ {
role: "model", role: "model",
parts: [{ text: `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.` }] parts: [{ text: 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.`
}]
} }
], ],
}); });
setChatSession(session); setChatSession(session);
// Get initial greeting in the selected language // Get initial greeting in the selected language
session.sendMessage(`Introduce yourself briefly to start our game. Keep it short and in ${language}.`).then(result => { const greetingPrompt = 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}.`;
session.sendMessage(greetingPrompt).then(result => {
const greetingText = result.response.text(); const greetingText = result.response.text();
setMessages([{ role: "model", text: greetingText, timestamp: Date.now() }]); setMessages([{ role: "model", text: greetingText, timestamp: Date.now() }]);
}).catch(err => { }).catch(err => {
console.error("Failed to get greeting:", err); console.error("Failed to get greeting:", err);
// Fallback to English if greeting fails // Fallback greeting
setMessages([{ role: "model", text: `Hello! I am ${personality.name}. Let's play!`, timestamp: Date.now() }]); const fallbackText = 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]); }, [apiKey, personality, language, playerColor, patternName]);
// NOTE: Removed solutionMoveKey from dependencies - we don't want to reset chat when puzzle changes
// Notify tutor about new puzzle (without resetting chat)
useEffect(() => {
if (!chatSession || !tacticalPracticeMode || !solutionMoveKey) return;
// Check if this is a new puzzle
if (currentPuzzleRef.current === solutionMoveKey) return;
// Skip the very first puzzle (greeting already sent)
if (currentPuzzleRef.current === null) {
currentPuzzleRef.current = solutionMoveKey;
return;
}
// Update the ref
currentPuzzleRef.current = solutionMoveKey;
// Notify the tutor about the new puzzle
const stats = tacticalPracticeMode.stats;
const statsText = stats ? `
STUDENT STATISTICS:
- Total Correct: ${stats.totalCorrect}
- Total Incorrect: ${stats.totalIncorrect}
- Current Streak: ${stats.currentStreak}
- Best Streak: ${stats.bestStreak}
` : '';
const newPuzzlePrompt = `
NEW PUZZLE:
- Pattern: ${tacticalPracticeMode.patternName}
- Position FEN: ${currentFen}
- Solution move: ${tacticalPracticeMode.solutionMove.from} to ${tacticalPracticeMode.solutionMove.to}
${statsText}
Acknowledge this new puzzle briefly (1 sentence) and encourage the student to find the ${tacticalPracticeMode.patternName}. ${stats && stats.currentStreak > 0 ? `Mention their current streak of ${stats.currentStreak} if it's impressive.` : ''} Keep it in ${language}.
`.trim();
chatSession.sendMessage(newPuzzlePrompt).then(result => {
const responseText = result.response.text();
setMessages(prev => [...prev, { role: "model", text: responseText, timestamp: Date.now() }]);
}).catch(err => {
console.error("Failed to notify about new puzzle:", err);
});
}, [solutionMoveKey, chatSession, tacticalPracticeMode, currentFen, language]);
// Scroll chat container to bottom (not the whole page) // Scroll chat container to bottom (not the whole page)
useEffect(() => { useEffect(() => {
@@ -354,7 +462,12 @@ React to this exchange as the player.
let finalPrompt = text; let finalPrompt = text;
if (!isSystemMessage) { if (!isSystemMessage) {
const lower = text.toLowerCase(); const lower = text.toLowerCase();
const evaluation = await evaluateCurrentPosition();
// In tactical practice mode, use the solution move instead of Stockfish
const evaluation = tacticalPracticeMode ? null : await evaluateCurrentPosition();
const bestMoveForHint = tacticalPracticeMode
? `${tacticalPracticeMode.solutionMove.from}${tacticalPracticeMode.solutionMove.to}${tacticalPracticeMode.solutionMove.promotion || ''}`
: evaluation?.bestMove;
if (lower.includes("best move") || lower.includes("solution") || lower.includes("tell me")) { if (lower.includes("best move") || lower.includes("solution") || lower.includes("tell me")) {
finalPrompt = `[SYSTEM TRIGGER: exact_move] finalPrompt = `[SYSTEM TRIGGER: exact_move]
@@ -368,19 +481,35 @@ User Question: ${text}
Current Position Data: Current Position Data:
- FEN: ${currentFen} - FEN: ${currentFen}
- Best Move: ${evaluation?.bestMove} - Best Move: ${bestMoveForHint || 'N/A'}
- Evaluation: ${evaluation?.score ?? 'N/A'} centipawns ${evaluation?.score !== undefined ? (evaluation.score > 0 ? '(White is better)' : evaluation.score < 0 ? '(Black is better)' : '(Equal)') : ''} - Evaluation: ${evaluation?.score ?? 'N/A'} centipawns ${evaluation?.score !== undefined ? (evaluation.score > 0 ? '(White is better)' : evaluation.score < 0 ? '(Black is better)' : '(Equal)') : ''}
- Mate in: ${evaluation?.mate || 'None'} - Mate in: ${evaluation?.mate || 'None'}
- Possible Openings: ${openingData && openingData.length > 0 ? openingData.map(o => `${o.name} (${o.eco})`).join(', ') : 'Unknown/Midgame'} - Possible Openings: ${openingData && openingData.length > 0 ? openingData.map(o => `${o.name} (${o.eco})`).join(', ') : 'Unknown/Midgame'}
${tacticalPracticeMode ? `- Tactical Pattern: ${tacticalPracticeMode.patternName}` : ''}
INSTRUCTIONS: INSTRUCTIONS:
- Tell them the best move clearly (e.g., "The best move is e2-e4" or "You should play Nf3") - Tell them the best move clearly (e.g., "The best move is e2-e4" or "You should play Nf3")
- Explain WHY it's the best move (tactics, threats, positional ideas) - Explain WHY it's the best move (tactics, threats, positional ideas)
${tacticalPracticeMode ? `- Explain how this move creates the ${tacticalPracticeMode.patternName} pattern` : ''}
- Stay in your personality style, but be HELPFUL and EDUCATIONAL - Stay in your personality style, but be HELPFUL and EDUCATIONAL
- Do NOT refuse to help - teaching is your core role - Do NOT refuse to help - teaching is your core role
- Keep it concise but informative`; - Keep it concise but informative`;
} else if (lower.includes("hint") || lower.includes("tip") || lower.includes("help")) { } else if (lower.includes("hint") || lower.includes("tip") || lower.includes("help")) {
// Calculate progress for multi-move puzzles
let progressInfo = '';
if (tacticalPracticeMode?.moves && tacticalPracticeMode.moves.length > 0) {
const totalPlayerMoves = tacticalPracticeMode.moves.filter(m => m.player).length;
const currentPlayerMove = Math.floor((tacticalPracticeMode.currentMoveIndex || 0) / 2) + 1;
progressInfo = `\n- Puzzle Progress: Move ${currentPlayerMove} of ${totalPlayerMoves}`;
// Show next expected move
const nextMove = tacticalPracticeMode.moves[tacticalPracticeMode.currentMoveIndex || 0];
if (nextMove && nextMove.player) {
progressInfo += `\n- Next Move to Find: ${nextMove.san} (${nextMove.uci})`;
}
}
finalPrompt = `[SYSTEM TRIGGER: hint] finalPrompt = `[SYSTEM TRIGGER: hint]
TEACHING MODE ACTIVATED: TEACHING MODE ACTIVATED:
@@ -392,14 +521,17 @@ User Question: ${text}
Current Position Data: Current Position Data:
- FEN: ${currentFen} - FEN: ${currentFen}
- Best Move: ${evaluation?.bestMove} - Best Move: ${bestMoveForHint || 'N/A'}
- Evaluation: ${evaluation?.score ?? 'N/A'} centipawns ${evaluation?.score !== undefined ? (evaluation.score > 0 ? '(White is better)' : evaluation.score < 0 ? '(Black is better)' : '(Equal)') : ''} - Evaluation: ${evaluation?.score ?? 'N/A'} centipawns ${evaluation?.score !== undefined ? (evaluation.score > 0 ? '(White is better)' : evaluation.score < 0 ? '(Black is better)' : '(Equal)') : ''}
- Mate in: ${evaluation?.mate || 'None'} - Mate in: ${evaluation?.mate || 'None'}
- Possible Openings: ${openingData && openingData.length > 0 ? openingData.map(o => `${o.name} (${o.eco})`).join(', ') : 'Unknown/Midgame'} - Possible Openings: ${openingData && openingData.length > 0 ? openingData.map(o => `${o.name} (${o.eco})`).join(', ') : 'Unknown/Midgame'}
${tacticalPracticeMode ? `- Tactical Pattern: ${tacticalPracticeMode.patternName}${progressInfo}` : ''}
INSTRUCTIONS: INSTRUCTIONS:
- Give a HELPFUL hint without revealing the exact move (unless they specifically ask for it) - Give a HELPFUL hint without revealing the exact move (unless they specifically ask for it)
- Point them toward what to look for: tactics, threats, piece placement, weaknesses - Point them toward what to look for: tactics, threats, piece placement, weaknesses
${tacticalPracticeMode ? `- Guide them to find the ${tacticalPracticeMode.patternName} pattern` : ''}
${tacticalPracticeMode?.moves && tacticalPracticeMode.moves.length > 1 ? '- This is a multi-move puzzle - guide them through the sequence step by step' : ''}
- Examples: "Look at your knight on f3", "There's a tactic involving the bishop and queen", "Your king is vulnerable" - Examples: "Look at your knight on f3", "There's a tactic involving the bishop and queen", "Your king is vulnerable"
- Stay in your personality style, but be HELPFUL and EDUCATIONAL - Stay in your personality style, but be HELPFUL and EDUCATIONAL
- Do NOT refuse to help - teaching is your core role - Do NOT refuse to help - teaching is your core role
@@ -412,9 +544,10 @@ User Question: ${text}
Current Position Context: Current Position Context:
- FEN: ${currentFen} - FEN: ${currentFen}
- Evaluation: ${evaluation?.score ?? 'N/A'} centipawns ${evaluation?.score !== undefined ? (evaluation.score > 0 ? '(White is better)' : evaluation.score < 0 ? '(Black is better)' : '(Equal)') : ''} - Evaluation: ${evaluation?.score ?? 'N/A'} centipawns ${evaluation?.score !== undefined ? (evaluation.score > 0 ? '(White is better)' : evaluation.score < 0 ? '(Black is better)' : '(Equal)') : ''}
- Best Move: ${evaluation?.bestMove ?? 'N/A'} - Best Move: ${bestMoveForHint ?? 'N/A'}
- Mate in: ${evaluation?.mate || 'None'} - Mate in: ${evaluation?.mate || 'None'}
- Possible Openings: ${openingData && openingData.length > 0 ? openingData.map(o => `${o.name} (${o.eco})`).join(', ') : 'Unknown/Midgame'} - Possible Openings: ${openingData && openingData.length > 0 ? openingData.map(o => `${o.name} (${o.eco})`).join(', ') : 'Unknown/Midgame'}
${tacticalPracticeMode ? `- Tactical Pattern: ${tacticalPracticeMode.patternName}` : ''}
INSTRUCTIONS: INSTRUCTIONS:
- Answer the user's question based on the CURRENT position data above - Answer the user's question based on the CURRENT position data above
+28 -20
View File
@@ -1,9 +1,8 @@
import { Chess } from "chess.js";
import { import {
Color, Color,
GeneratedTacticPosition, GeneratedTacticPosition,
TacticalPatternType, TacticalPatternType,
detectTacticsForSide,
findTacticalOpportunitiesForSide,
generateBackRankWeaknessPosition, generateBackRankWeaknessPosition,
generateDiscoveredCheckPosition, generateDiscoveredCheckPosition,
generateDoubleAttackPosition, generateDoubleAttackPosition,
@@ -27,32 +26,41 @@ const generators: GeneratorEntry[] = [
{ patternType: "DISCOVERED_CHECK", side: "white", generator: () => generateDiscoveredCheckPosition({ side: "white" }) }, { patternType: "DISCOVERED_CHECK", side: "white", generator: () => generateDiscoveredCheckPosition({ side: "white" }) },
{ patternType: "DOUBLE_ATTACK", side: "white", generator: () => generateDoubleAttackPosition({ side: "white" }) }, { patternType: "DOUBLE_ATTACK", side: "white", generator: () => generateDoubleAttackPosition({ side: "white" }) },
{ patternType: "OVERLOADING", side: "white", generator: () => generateOverloadingPosition({ side: "white" }) }, { patternType: "OVERLOADING", side: "white", generator: () => generateOverloadingPosition({ side: "white" }) },
{ patternType: "BACK_RANK_WEAKNESS", side: "black", generator: () => generateBackRankWeaknessPosition({ side: "black" }) }, { patternType: "BACK_RANK_WEAKNESS", side: "white", generator: () => generateBackRankWeaknessPosition({ side: "white" }) },
{ patternType: "TRAPPED_PIECE", side: "black", generator: () => generateTrappedPiecePosition({ side: "black" }) }, { patternType: "TRAPPED_PIECE", side: "white", generator: () => generateTrappedPiecePosition({ side: "white" }) },
]; ];
describe("tactic generators", () => { describe("tactic generators", () => {
it.each(generators)( it.each(generators)(
"creates scenarios where opportunities expose %s", "generates valid %s tactical puzzles from Lichess database",
({ patternType, side, generator }) => { ({ patternType, side, generator }) => {
const scenario = generator(); const scenario = generator();
const opportunities = findTacticalOpportunitiesForSide(scenario.initialPosition, side); // Verify the scenario has all required fields
const opportunity = opportunities.find( expect(scenario.initialPosition.fen).toBeTruthy();
(o) => expect(scenario.creatingMove).toBeDefined();
o.move.from === scenario.creatingMove.from && expect(scenario.creatingMove.from).toBeTruthy();
o.move.to === scenario.creatingMove.to && expect(scenario.creatingMove.to).toBeTruthy();
o.pattern.type === patternType,
);
expect(opportunity).toBeDefined();
expect(opportunity?.pattern).toMatchObject({ type: patternType });
const resultingPatterns = detectTacticsForSide(scenario.resultingPosition, side);
const directMatch = resultingPatterns.find((p) => p.type === patternType);
expect(directMatch).toBeDefined();
expect(scenario.resultingPosition.fen).toBeTruthy(); expect(scenario.resultingPosition.fen).toBeTruthy();
expect(scenario.expectedPattern.type).toBe(patternType);
expect(scenario.side).toBe(side);
// Verify the first move (bestMove) is legal in the initial position
const chess = new Chess(scenario.initialPosition.fen);
const firstMove = chess.move({
from: scenario.creatingMove.from,
to: scenario.creatingMove.to,
promotion: scenario.creatingMove.promotion,
});
expect(firstMove).toBeTruthy();
expect(firstMove).toMatchObject({
from: scenario.creatingMove.from,
to: scenario.creatingMove.to,
});
// Verify it's the correct side's turn
const turnColor = scenario.initialPosition.fen.split(' ')[1];
expect(turnColor).toBe(side === 'white' ? 'w' : 'b');
}, },
); );
}); });
+182
View File
@@ -45,6 +45,7 @@ export interface Translations {
playAsBlack: string; playAsBlack: string;
randomColor: string; randomColor: string;
analyzeGame: string; analyzeGame: string;
learningArea: string;
savedGamesTitle: string; savedGamesTitle: string;
savedGamesEmpty: string; savedGamesEmpty: string;
opponentLabel: string; opponentLabel: string;
@@ -181,6 +182,37 @@ export interface Translations {
}; };
stepIndicator: (step: number, total: number) => string; stepIndicator: (step: number, total: number) => string;
}; };
// Learning Area
learning: {
title: string;
subtitle: string;
tacticalPatterns: string;
openings: string;
comingSoon: string;
backToMenu: string;
patterns: {
pin: string;
skewer: string;
fork: string;
discoveredCheck: string;
doubleAttack: string;
overloading: string;
backRankWeakness: string;
trappedPiece: string;
};
practice: {
title: string;
hint: string;
makeYourMove: string;
correct: string;
incorrect: string;
tryAgain: string;
nextExercise: string;
backToLearning: string;
findTheMove: string;
};
};
} }
const en: Translations = { const en: Translations = {
@@ -223,6 +255,7 @@ const en: Translations = {
playAsBlack: 'Play as Black', playAsBlack: 'Play as Black',
randomColor: 'Random', randomColor: 'Random',
analyzeGame: 'Analyze a Game', analyzeGame: 'Analyze a Game',
learningArea: 'Learning Area',
savedGamesTitle: 'Unfinished games', savedGamesTitle: 'Unfinished games',
savedGamesEmpty: 'No unfinished games yet.', savedGamesEmpty: 'No unfinished games yet.',
opponentLabel: 'Opponent', opponentLabel: 'Opponent',
@@ -352,6 +385,35 @@ const en: Translations = {
}, },
stepIndicator: (step: number, total: number) => `Step ${step} of ${total}`, stepIndicator: (step: number, total: number) => `Step ${step} of ${total}`,
}, },
learning: {
title: 'Learning Area',
subtitle: 'Practice tactical patterns and openings',
tacticalPatterns: 'Tactical Patterns',
openings: 'Openings',
comingSoon: 'Coming Soon',
backToMenu: 'Back to Menu',
patterns: {
pin: 'Pin',
skewer: 'Skewer',
fork: 'Fork',
discoveredCheck: 'Discovered Check',
doubleAttack: 'Double Attack',
overloading: 'Overloading',
backRankWeakness: 'Back Rank Weakness',
trappedPiece: 'Trapped Piece',
},
practice: {
title: 'Tactical Practice',
hint: 'Hint',
makeYourMove: 'Make your move on the board',
correct: 'Correct! Well done!',
incorrect: 'Not quite. Try again!',
tryAgain: 'Try Again',
nextExercise: 'Next Exercise',
backToLearning: 'Back to Learning Area',
findTheMove: 'Find the move that creates a',
},
},
}; };
const de: Translations = { const de: Translations = {
@@ -394,6 +456,7 @@ const de: Translations = {
playAsBlack: 'Als Schwarz spielen', playAsBlack: 'Als Schwarz spielen',
randomColor: 'Zufällig', randomColor: 'Zufällig',
analyzeGame: 'Partie analysieren', analyzeGame: 'Partie analysieren',
learningArea: 'Lernbereich',
savedGamesTitle: 'Unfertige Partien', savedGamesTitle: 'Unfertige Partien',
savedGamesEmpty: 'Keine unfertigen Partien vorhanden.', savedGamesEmpty: 'Keine unfertigen Partien vorhanden.',
opponentLabel: 'Gegner', opponentLabel: 'Gegner',
@@ -523,6 +586,35 @@ const de: Translations = {
}, },
stepIndicator: (step: number, total: number) => `Schritt ${step} von ${total}`, stepIndicator: (step: number, total: number) => `Schritt ${step} von ${total}`,
}, },
learning: {
title: 'Lernbereich',
subtitle: 'Übe taktische Muster und Eröffnungen',
tacticalPatterns: 'Taktische Muster',
openings: 'Eröffnungen',
comingSoon: 'Demnächst',
backToMenu: 'Zurück zum Menü',
patterns: {
pin: 'Fesselung',
skewer: 'Spieß',
fork: 'Gabel',
discoveredCheck: 'Abzugsschach',
doubleAttack: 'Doppelangriff',
overloading: 'Überlastung',
backRankWeakness: 'Grundreihenschwäche',
trappedPiece: 'Gefangene Figur',
},
practice: {
title: 'Taktiktraining',
hint: 'Hinweis',
makeYourMove: 'Mache deinen Zug auf dem Brett',
correct: 'Richtig! Gut gemacht!',
incorrect: 'Nicht ganz. Versuch es nochmal!',
tryAgain: 'Nochmal versuchen',
nextExercise: 'Nächste Übung',
backToLearning: 'Zurück zum Lernbereich',
findTheMove: 'Finde den Zug, der eine',
},
},
}; };
const fr: Translations = { const fr: Translations = {
@@ -565,6 +657,7 @@ const fr: Translations = {
playAsBlack: 'Jouer Noirs', playAsBlack: 'Jouer Noirs',
randomColor: 'Aléatoire', randomColor: 'Aléatoire',
analyzeGame: 'Analyser une partie', analyzeGame: 'Analyser une partie',
learningArea: 'Zone d\'apprentissage',
savedGamesTitle: 'Parties inachevées', savedGamesTitle: 'Parties inachevées',
savedGamesEmpty: 'Aucune partie en cours.', savedGamesEmpty: 'Aucune partie en cours.',
opponentLabel: 'Adversaire', opponentLabel: 'Adversaire',
@@ -694,6 +787,35 @@ const fr: Translations = {
}, },
stepIndicator: (step: number, total: number) => `Étape ${step} sur ${total}`, stepIndicator: (step: number, total: number) => `Étape ${step} sur ${total}`,
}, },
learning: {
title: 'Zone d\'apprentissage',
subtitle: 'Pratiquez les motifs tactiques et les ouvertures',
tacticalPatterns: 'Motifs tactiques',
openings: 'Ouvertures',
comingSoon: 'Bientôt disponible',
backToMenu: 'Retour au menu',
patterns: {
pin: 'Clouage',
skewer: 'Enfilade',
fork: 'Fourchette',
discoveredCheck: 'Échec à la découverte',
doubleAttack: 'Double attaque',
overloading: 'Surcharge',
backRankWeakness: 'Faiblesse de la dernière rangée',
trappedPiece: 'Pièce piégée',
},
practice: {
title: 'Pratique tactique',
hint: 'Indice',
makeYourMove: 'Faites votre coup sur l\'échiquier',
correct: 'Correct ! Bien joué !',
incorrect: 'Pas tout à fait. Réessayez !',
tryAgain: 'Réessayer',
nextExercise: 'Exercice suivant',
backToLearning: 'Retour à la zone d\'apprentissage',
findTheMove: 'Trouvez le coup qui crée un',
},
},
}; };
const it: Translations = { const it: Translations = {
@@ -736,6 +858,7 @@ const it: Translations = {
playAsBlack: 'Gioca Nero', playAsBlack: 'Gioca Nero',
randomColor: 'Casuale', randomColor: 'Casuale',
analyzeGame: 'Analizza una partita', analyzeGame: 'Analizza una partita',
learningArea: 'Area di apprendimento',
savedGamesTitle: 'Partite non finite', savedGamesTitle: 'Partite non finite',
savedGamesEmpty: 'Nessuna partita in corso.', savedGamesEmpty: 'Nessuna partita in corso.',
opponentLabel: 'Avversario', opponentLabel: 'Avversario',
@@ -865,6 +988,35 @@ const it: Translations = {
}, },
stepIndicator: (step: number, total: number) => `Passo ${step} di ${total}`, stepIndicator: (step: number, total: number) => `Passo ${step} di ${total}`,
}, },
learning: {
title: 'Area di apprendimento',
subtitle: 'Pratica schemi tattici e aperture',
tacticalPatterns: 'Schemi tattici',
openings: 'Aperture',
comingSoon: 'Prossimamente',
backToMenu: 'Torna al menu',
patterns: {
pin: 'Inchiodatura',
skewer: 'Infilata',
fork: 'Forchetta',
discoveredCheck: 'Scacco di scoperta',
doubleAttack: 'Doppio attacco',
overloading: 'Sovraccarico',
backRankWeakness: 'Debolezza dell\'ultima traversa',
trappedPiece: 'Pezzo intrappolato',
},
practice: {
title: 'Pratica tattica',
hint: 'Suggerimento',
makeYourMove: 'Fai la tua mossa sulla scacchiera',
correct: 'Corretto! Ben fatto!',
incorrect: 'Non proprio. Riprova!',
tryAgain: 'Riprova',
nextExercise: 'Prossimo esercizio',
backToLearning: 'Torna all\'area di apprendimento',
findTheMove: 'Trova la mossa che crea un',
},
},
}; };
const pl: Translations = { const pl: Translations = {
@@ -907,6 +1059,7 @@ const pl: Translations = {
playAsBlack: 'Graj czarnymi', playAsBlack: 'Graj czarnymi',
randomColor: 'Losowo', randomColor: 'Losowo',
analyzeGame: 'Analizuj partię', analyzeGame: 'Analizuj partię',
learningArea: 'Strefa nauki',
savedGamesTitle: 'Niedokończone partie', savedGamesTitle: 'Niedokończone partie',
savedGamesEmpty: 'Brak niedokończonych partii.', savedGamesEmpty: 'Brak niedokończonych partii.',
opponentLabel: 'Przeciwnik', opponentLabel: 'Przeciwnik',
@@ -1036,6 +1189,35 @@ const pl: Translations = {
}, },
stepIndicator: (step: number, total: number) => `Krok ${step} z ${total}`, stepIndicator: (step: number, total: number) => `Krok ${step} z ${total}`,
}, },
learning: {
title: 'Strefa nauki',
subtitle: 'Ćwicz wzorce taktyczne i otwarcia',
tacticalPatterns: 'Wzorce taktyczne',
openings: 'Otwarcia',
comingSoon: 'Wkrótce',
backToMenu: 'Powrót do menu',
patterns: {
pin: 'Związanie',
skewer: 'Szpikulec',
fork: 'Widły',
discoveredCheck: 'Szach z odkrycia',
doubleAttack: 'Podwójny atak',
overloading: 'Przeciążenie',
backRankWeakness: 'Słabość ostatniej linii',
trappedPiece: 'Uwięziona figura',
},
practice: {
title: 'Trening taktyczny',
hint: 'Podpowiedź',
makeYourMove: 'Wykonaj ruch na szachownicy',
correct: 'Poprawnie! Dobra robota!',
incorrect: 'Nie do końca. Spróbuj ponownie!',
tryAgain: 'Spróbuj ponownie',
nextExercise: 'Następne ćwiczenie',
backToLearning: 'Powrót do strefy nauki',
findTheMove: 'Znajdź ruch, który tworzy',
},
},
}; };
export const translations: Record<SupportedLanguage, Translations> = { export const translations: Record<SupportedLanguage, Translations> = {
+328 -24
View File
@@ -71,13 +71,22 @@ export interface TacticExerciseParams {
patternType: TacticalPatternType; patternType: TacticalPatternType;
side: Color; side: Color;
maxDepthFromInitial?: number; maxDepthFromInitial?: number;
difficulty?: 'easy' | 'medium' | 'hard'; // Difficulty filter
}
export interface PuzzleMove {
uci: string;
san: string;
player: boolean; // true if player move, false if opponent move
} }
export interface TacticExercise { export interface TacticExercise {
startPosition: Position; startPosition: Position;
solutionMove: Move; solutionMove: Move; // First player move (for backward compatibility)
resultPosition: Position; resultPosition: Position;
pattern: TacticalPattern; pattern: TacticalPattern;
moves?: PuzzleMove[]; // Full move sequence (optional for backward compatibility)
rating?: number; // Puzzle difficulty rating
} }
export interface GeneratedTacticPosition { export interface GeneratedTacticPosition {
@@ -121,9 +130,8 @@ type PieceInfo = { square: Square; piece: Piece };
function attackMap(chess: Chess, side: Color): Map<Square, Square[]> { function attackMap(chess: Chess, side: Color): Map<Square, Square[]> {
const attacks = new Map<Square, Square[]>(); const attacks = new Map<Square, Square[]>();
for (const { square } of collectPieces(chess, side)) { for (const { square } of collectPieces(chess, side)) {
const moves = chess.moves({ square, verbose: true }) as ChessMove[]; const targets = squaresAttackedBy(chess, square);
for (const move of moves) { for (const target of targets) {
const target = move.to as Square;
if (!attacks.has(target)) attacks.set(target, []); if (!attacks.has(target)) attacks.set(target, []);
attacks.get(target)!.push(square); attacks.get(target)!.push(square);
} }
@@ -148,6 +156,24 @@ function collectPieces(chess: Chess, side: Color): PieceInfo[] {
return result; return result;
} }
function findKing(chess: Chess, side: Color): Square | null {
const pieces = collectPieces(chess, side);
const king = pieces.find(p => p.piece.type === "k");
return king ? king.square : null;
}
function findAttackersOfSquare(chess: Chess, target: Square, attackingSide: Color): Square[] {
const attackers: Square[] = [];
const pieces = collectPieces(chess, attackingSide);
for (const { square } of pieces) {
const attacks = squaresAttackedBy(chess, square);
if (attacks.includes(target)) {
attackers.push(square);
}
}
return attackers;
}
function raySquares(from: Square, df: number, dr: number): Square[] { function raySquares(from: Square, df: number, dr: number): Square[] {
const squares: Square[] = []; const squares: Square[] = [];
const fileIndex = FILES.indexOf(from[0] as (typeof FILES)[number]); const fileIndex = FILES.indexOf(from[0] as (typeof FILES)[number]);
@@ -204,24 +230,65 @@ function detectPinsAndSkewers(chess: Chess, side: Color): TacticalPattern[] {
for (const [df, dr] of directions[slider.piece.type]) { for (const [df, dr] of directions[slider.piece.type]) {
const ray = raySquares(slider.square, df, dr); const ray = raySquares(slider.square, df, dr);
const seen: Array<PieceInfo & { color: Color }> = []; const seen: Array<PieceInfo & { color: Color }> = [];
// Collect all pieces on the ray (not just first 2)
for (const square of ray) { for (const square of ray) {
const occupier = chess.get(square); const occupier = chess.get(square);
if (occupier) { if (occupier) {
seen.push({ square, piece: occupier, color: occupier.color === "w" ? "white" : "black" }); seen.push({ square, piece: occupier, color: occupier.color === "w" ? "white" : "black" });
if (seen.length === 2) break;
} }
} }
// Need at least 2 pieces for a pin or skewer
if (seen.length < 2) continue; if (seen.length < 2) continue;
const [first, second] = seen;
if (first.color !== opponent || second.color !== opponent) continue; // Check all pairs of opponent pieces on the ray
const firstValue = pieceValues[first.piece.type]; for (let i = 0; i < seen.length - 1; i++) {
const secondValue = pieceValues[second.piece.type]; const first = seen[i];
const isPin = second.piece.type === "k" || secondValue > firstValue;
const isSkewer = firstValue > secondValue && firstValue >= 300; // First piece must be opponent's
if (isPin) { if (first.color !== opponent) continue;
results.push({ type: "PIN", side, attackerSquares: [slider.square], targetSquares: [first.square], keySquares: [second.square] });
} else if (isSkewer) { // Find the next opponent piece after 'first'
results.push({ type: "SKEWER", side, attackerSquares: [slider.square], targetSquares: [first.square, second.square] }); let second: (PieceInfo & { color: Color }) | null = null;
for (let j = i + 1; j < seen.length; j++) {
if (seen[j].color === opponent) {
second = seen[j];
break;
}
}
if (!second) continue;
const firstValue = pieceValues[first.piece.type];
const secondValue = pieceValues[second.piece.type];
// PIN: The second piece is the king OR more valuable than the first
// This pins the first piece because moving it would expose the more valuable second piece
const isPin = second.piece.type === "k" || secondValue > firstValue;
// SKEWER: The first piece is more valuable than the second
// This forces the first piece to move, exposing the second piece
const isSkewer = firstValue > secondValue && firstValue >= 300;
if (isPin) {
results.push({
type: "PIN",
side,
attackerSquares: [slider.square],
targetSquares: [first.square],
keySquares: [second.square]
});
// If we found a pin to the king, that's the most important one for this ray
if (second.piece.type === "k") break;
} else if (isSkewer) {
results.push({
type: "SKEWER",
side,
attackerSquares: [slider.square],
targetSquares: [first.square, second.square]
});
}
} }
} }
} }
@@ -229,10 +296,98 @@ function detectPinsAndSkewers(chess: Chess, side: Color): TacticalPattern[] {
} }
function squaresAttackedBy(chess: Chess, from: Square): Square[] { function squaresAttackedBy(chess: Chess, from: Square): Square[] {
const piece = chess.get(from);
if (!piece) return [];
// For pieces that aren't the current side to move, we need to calculate attacks manually
// because chess.moves() only returns moves for the side to move
const currentTurn = chess.turn();
if (piece.color !== currentTurn) {
return squaresAttackedByPiece(chess, from, piece);
}
const moves = chess.moves({ square: from, verbose: true }) as ChessMove[]; const moves = chess.moves({ square: from, verbose: true }) as ChessMove[];
return moves.map(m => m.to as Square); return moves.map(m => m.to as Square);
} }
function squaresAttackedByPiece(chess: Chess, from: Square, piece: Piece): Square[] {
const attacks: Square[] = [];
const fileIndex = FILES.indexOf(from[0] as (typeof FILES)[number]);
const rankIndex = parseInt(from[1], 10) - 1;
const trySquare = (file: number, rank: number): Square | null => {
if (file < 0 || file > 7 || rank < 0 || rank > 7) return null;
return `${FILES[file]}${rank + 1}` as Square;
};
switch (piece.type) {
case "p": {
// Pawns attack diagonally
const direction = piece.color === "w" ? 1 : -1;
const left = trySquare(fileIndex - 1, rankIndex + direction);
const right = trySquare(fileIndex + 1, rankIndex + direction);
if (left) attacks.push(left);
if (right) attacks.push(right);
break;
}
case "n": {
// Knight moves
const offsets = [
[-2, -1], [-2, 1], [-1, -2], [-1, 2],
[1, -2], [1, 2], [2, -1], [2, 1]
];
for (const [df, dr] of offsets) {
const sq = trySquare(fileIndex + df, rankIndex + dr);
if (sq) attacks.push(sq);
}
break;
}
case "b": {
// Bishop moves (diagonals)
for (const [df, dr] of [[1, 1], [1, -1], [-1, 1], [-1, -1]]) {
const ray = raySquares(from, df, dr);
for (const sq of ray) {
attacks.push(sq);
if (chess.get(sq)) break; // Stop at first piece
}
}
break;
}
case "r": {
// Rook moves (straight lines)
for (const [df, dr] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
const ray = raySquares(from, df, dr);
for (const sq of ray) {
attacks.push(sq);
if (chess.get(sq)) break; // Stop at first piece
}
}
break;
}
case "q": {
// Queen moves (diagonals + straight lines)
for (const [df, dr] of [[1, 1], [1, -1], [-1, 1], [-1, -1], [1, 0], [-1, 0], [0, 1], [0, -1]]) {
const ray = raySquares(from, df, dr);
for (const sq of ray) {
attacks.push(sq);
if (chess.get(sq)) break; // Stop at first piece
}
}
break;
}
case "k": {
// King moves (one square in any direction)
for (const [df, dr] of [[1, 1], [1, -1], [-1, 1], [-1, -1], [1, 0], [-1, 0], [0, 1], [0, -1]]) {
const sq = trySquare(fileIndex + df, rankIndex + dr);
if (sq) attacks.push(sq);
}
break;
}
}
return attacks;
}
function detectFork(chess: Chess, side: Color): TacticalPattern[] { function detectFork(chess: Chess, side: Color): TacticalPattern[] {
const patterns: TacticalPattern[] = []; const patterns: TacticalPattern[] = [];
for (const { square } of collectPieces(chess, side)) { for (const { square } of collectPieces(chess, side)) {
@@ -297,7 +452,22 @@ function detectDoubleAttack(chess: Chess, side: Color): TacticalPattern[] {
.filter(op => valuable(op.piece) && attacks.has(op.square)) .filter(op => valuable(op.piece) && attacks.has(op.square))
.map(op => op.square); .map(op => op.square);
if (threatenedTargets.length >= 2) { if (threatenedTargets.length >= 2) {
return [{ type: "DOUBLE_ATTACK", side, attackerSquares: [], targetSquares: threatenedTargets }]; // Find all pieces that attack at least 2 of the threatened targets
const attackerSquares = new Set<Square>();
for (const target of threatenedTargets) {
const attackers = attacks.get(target) || [];
for (const attacker of attackers) {
// Check if this attacker attacks at least 2 targets
const targetsAttackedByThis = threatenedTargets.filter(t => {
const attackersOfT = attacks.get(t) || [];
return attackersOfT.includes(attacker);
});
if (targetsAttackedByThis.length >= 2) {
attackerSquares.add(attacker);
}
}
}
return [{ type: "DOUBLE_ATTACK", side, attackerSquares: Array.from(attackerSquares), targetSquares: threatenedTargets }];
} }
return []; return [];
} }
@@ -393,10 +563,116 @@ export function findTacticalOpportunitiesForSide(position: Position, side: Color
const moves = chess.moves({ verbose: true }) as ChessMove[]; const moves = chess.moves({ verbose: true }) as ChessMove[];
for (const move of moves) { for (const move of moves) {
if (move.color !== toChessColor(side)) continue; if (move.color !== toChessColor(side)) continue;
// Skip moves that capture the king (they create invalid positions)
if (move.captured === "k") continue;
const clone = new Chess(position.fen); const clone = new Chess(position.fen);
clone.move(move); clone.move(move);
// Check for trapped piece capture
// If the move captures a piece that had no legal moves in the initial position, it's capturing a trapped piece
if (move.captured) {
const capturedSquare = move.to;
const initialChess = new Chess(position.fen);
const capturedPieceMoves = initialChess.moves({ square: capturedSquare, verbose: true }) as ChessMove[];
if (capturedPieceMoves.length === 0) {
// The captured piece was trapped
opportunities.push({
move: { from: move.from, to: move.to, promotion: move.promotion as Move["promotion"] | undefined },
pattern: {
type: "TRAPPED_PIECE",
side,
attackerSquares: [move.from],
targetSquares: [capturedSquare],
},
});
}
}
// Check for discovered check/attack
// A discovered check occurs when moving a piece reveals an attack from another piece
if (clone.inCheck()) {
// Find which piece is giving check
const opponent = side === "white" ? "black" : "white";
const opponentKingSquare = findKing(clone, opponent);
if (opponentKingSquare) {
const attackers = findAttackersOfSquare(clone, opponentKingSquare, side);
// If the checking piece is not the piece that moved, it's a discovered check
for (const attacker of attackers) {
if (attacker !== move.to) {
const targetPiece = clone.get(opponentKingSquare);
const type: TacticalPatternType = targetPiece?.type === "k" ? "DISCOVERED_CHECK" : "DISCOVERED_ATTACK";
opportunities.push({
move: { from: move.from, to: move.to, promotion: move.promotion as Move["promotion"] | undefined },
pattern: {
type,
side,
attackerSquares: [attacker],
targetSquares: [opponentKingSquare],
keySquares: [move.to], // The piece that moved away
},
});
}
}
}
}
// Check for overloading
// Overloading occurs when a defender must choose between two defensive duties
// Common pattern: a piece defends both a square and the back rank
if (clone.inCheck()) {
const opponent = side === "white" ? "black" : "white";
const opponentKingSquare = findKing(clone, opponent);
if (opponentKingSquare) {
// Find pieces that can capture the checking piece
const opponentMoves = clone.moves({ verbose: true }) as ChessMove[];
const capturingMoves = opponentMoves.filter(m => m.to === move.to && m.from !== opponentKingSquare);
// For each capturing move, check if the capturing piece was defending something important
for (const captureMove of capturingMoves) {
const defenderSquare = captureMove.from;
// Check what the defender was defending before it captures
const beforeCapture = new Chess(position.fen);
const defenderAttacks = squaresAttackedBy(beforeCapture, defenderSquare);
// Count how many valuable pieces/squares the defender was protecting
let defendedCount = 0;
const defendedSquares: Square[] = [];
for (const sq of defenderAttacks) {
const piece = beforeCapture.get(sq);
if (piece && piece.color === toChessColor(opponent) && valuable(piece)) {
defendedCount++;
defendedSquares.push(sq);
}
}
// If the defender was protecting 2+ things (including the square it's on), it's overloaded
if (defendedCount >= 1 || defenderAttacks.length >= 3) {
opportunities.push({
move: { from: move.from, to: move.to, promotion: move.promotion as Move["promotion"] | undefined },
pattern: {
type: "OVERLOADING",
side,
attackerSquares: [move.to],
targetSquares: [defenderSquare, move.to],
},
});
break; // Found overloading
}
}
}
}
const patterns = detectTacticsForSide({ fen: clone.fen() }, side); const patterns = detectTacticsForSide({ fen: clone.fen() }, side);
for (const pattern of patterns) { for (const pattern of patterns) {
// Skip patterns we handle specially above
if (pattern.type === "DISCOVERED_CHECK" || pattern.type === "DISCOVERED_ATTACK") continue;
if (pattern.type === "TRAPPED_PIECE") continue; // We detect this specially above
if (pattern.type === "OVERLOADING") continue; // We detect this specially above
opportunities.push({ opportunities.push({
move: { from: move.from, to: move.to, promotion: move.promotion as Move["promotion"] | undefined }, move: { from: move.from, to: move.to, promotion: move.promotion as Move["promotion"] | undefined },
pattern, pattern,
@@ -413,6 +689,10 @@ export function findRiskyMoves(position: Position, side: Color): TacticalRisk[]
const opponent: Color = side === "white" ? "black" : "white"; const opponent: Color = side === "white" ? "black" : "white";
for (const move of moves) { for (const move of moves) {
if (move.color !== toChessColor(side)) continue; if (move.color !== toChessColor(side)) continue;
// Skip moves that capture the king (they create invalid positions)
if (move.captured === "k") continue;
const clone = new Chess(position.fen); const clone = new Chess(position.fen);
clone.move(move); clone.move(move);
const opponentPatterns = findTacticalOpportunitiesForSide({ fen: clone.fen() }, opponent).map(o => o.pattern); const opponentPatterns = findTacticalOpportunitiesForSide({ fen: clone.fen() }, opponent).map(o => o.pattern);
@@ -432,7 +712,7 @@ export function identifyGambit(movesSan: string[]): GambitMatch | null {
} }
export function listPossibleGambits(movesSan: string[]): GambitMatch[] { export function listPossibleGambits(movesSan: string[]): GambitMatch[] {
const definitions = gambitDefinitions as GambleDefinition[]; const definitions = (gambitDefinitions as any).gambits as GambleDefinition[];
const matches: GambitMatch[] = []; const matches: GambitMatch[] = [];
for (const gambit of definitions) { for (const gambit of definitions) {
let matched = 0; let matched = 0;
@@ -455,20 +735,44 @@ export function listPossibleGambits(movesSan: string[]): GambitMatch[] {
export function generateTacticExercise(params: TacticExerciseParams): TacticExercise { export function generateTacticExercise(params: TacticExerciseParams): TacticExercise {
const dataset = tacticalFixtures[params.patternType]; const dataset = tacticalFixtures[params.patternType];
const cases = dataset.cases.filter((c: any) => c.sideToMove === params.side);
const chosen = cases[0] || dataset.cases[0]; // Filter by side
if (!chosen) { let cases = dataset.cases.filter((c: any) => c.sideToMove === params.side);
throw new Error(`No fixture available for pattern ${params.patternType}`);
// Filter by difficulty if specified
if (params.difficulty) {
const difficultyRanges = {
easy: { min: 800, max: 1400 },
medium: { min: 1400, max: 1800 },
hard: { min: 1800, max: 2200 },
};
const range = difficultyRanges[params.difficulty];
cases = cases.filter((c: any) => {
const rating = c.rating || 1500; // Default to medium if no rating
return rating >= range.min && rating < range.max;
});
} }
const pool = cases.length > 0 ? cases : dataset.cases;
if (pool.length === 0) {
throw new Error(`No fixture available for pattern ${params.patternType} with difficulty ${params.difficulty || 'any'}`);
}
// Pick a random case instead of always the first one
const chosen = pool[Math.floor(Math.random() * pool.length)];
return { return {
startPosition: { fen: chosen.initialFen }, startPosition: { fen: chosen.initialFen },
solutionMove: { solutionMove: {
from: chosen.bestMove.uci.substring(0, 2), from: chosen.bestMove.uci.substring(0, 2),
to: chosen.bestMove.uci.substring(2, 4), to: chosen.bestMove.uci.substring(2, 4),
promotion: chosen.bestMove.uci.length > 4 ? chosen.bestMove.uci.substring(4, 5) : undefined, promotion: chosen.bestMove.uci.length > 4 ? (chosen.bestMove.uci.substring(4, 5) as Move["promotion"]) : undefined,
}, },
resultPosition: { fen: chosen.resultingFen }, resultPosition: { fen: chosen.resultingFen },
pattern: chosen.expectedPattern, pattern: chosen.expectedPattern as TacticalPattern,
moves: chosen.moves, // Include full move sequence
rating: chosen.rating, // Include puzzle rating
}; };
} }
@@ -506,7 +810,7 @@ export function generateTacticPosition(
initialPosition: { fen: chosen.initialFen }, initialPosition: { fen: chosen.initialFen },
creatingMove, creatingMove,
resultingPosition: { fen: chosen.resultingFen }, resultingPosition: { fen: chosen.resultingFen },
expectedPattern: chosen.expectedPattern, expectedPattern: chosen.expectedPattern as TacticalPattern,
}; };
} }