playwright
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+4406
-15
File diff suppressed because it is too large
Load Diff
+4378
-15
File diff suppressed because it is too large
Load Diff
+4476
-16
File diff suppressed because it is too large
Load Diff
+4455
-17
File diff suppressed because it is too large
Load Diff
+4168
-15
File diff suppressed because it is too large
Load Diff
Generated
+64
-20
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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');
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user