react-chess-puzzle-kit 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +331 -0
- package/dist/features/analysis/AnalysisBoard.d.ts +18 -0
- package/dist/features/analysis/AnalysisBoardCore.d.ts +28 -0
- package/dist/features/analysis/AnalysisBoardLayout.d.ts +12 -0
- package/dist/features/analysis/AnalysisChessboardView.d.ts +5 -0
- package/dist/features/analysis/AnalysisPosition.d.ts +53 -0
- package/dist/features/analysis/AnalysisTrigger.d.ts +6 -0
- package/dist/features/analysis/DefaultAnalysisContainer.d.ts +3 -0
- package/dist/features/analysis/DefaultAnalysisSidebar.d.ts +2 -0
- package/dist/features/analysis/analysisBoardHighlightColors.d.ts +12 -0
- package/dist/features/analysis/analysisContext.d.ts +8 -0
- package/dist/features/analysis/analysisLayout.d.ts +5 -0
- package/dist/features/analysis/core/AnalysisBoardCore.d.ts +28 -0
- package/dist/features/analysis/core/AnalysisChessboardView.d.ts +5 -0
- package/dist/features/analysis/core/AnalysisErrorBoundary.d.ts +14 -0
- package/dist/features/analysis/core/AnalysisPosition.d.ts +54 -0
- package/dist/features/analysis/core/analysisContext.d.ts +13 -0
- package/dist/features/analysis/core/analysisLayout.d.ts +5 -0
- package/dist/features/analysis/core/analysisLayoutConfig.d.ts +6 -0
- package/dist/features/analysis/core/index.d.ts +9 -0
- package/dist/features/analysis/core/renderProps.d.ts +39 -0
- package/dist/features/analysis/core/useAnalysisBoardModel.d.ts +36 -0
- package/dist/features/analysis/core/usePuzzleAnalysis.d.ts +10 -0
- package/dist/features/analysis/defaults/AnalysisBoard.d.ts +16 -0
- package/dist/features/analysis/defaults/AnalysisBoardLayout.d.ts +14 -0
- package/dist/features/analysis/defaults/DefaultAnalysisContainer.d.ts +4 -0
- package/dist/features/analysis/defaults/DefaultAnalysisSidebar.d.ts +3 -0
- package/dist/features/analysis/defaults/analysisLayout.d.ts +3 -0
- package/dist/features/analysis/defaults/analysisModalStyles.d.ts +7 -0
- package/dist/features/analysis/defaults/analysisSidebarColors.d.ts +37 -0
- package/dist/features/analysis/defaults/analysisSidebarRowStyle.d.ts +8 -0
- package/dist/features/analysis/defaults/index.d.ts +8 -0
- package/dist/features/analysis/index.d.ts +3 -0
- package/dist/features/analysis/renderProps.d.ts +39 -0
- package/dist/features/analysis/useAnalysisBoardModel.d.ts +33 -0
- package/dist/features/analysis/usePuzzleAnalysis.d.ts +10 -0
- package/dist/features/board/BlankPuzzleBoard.d.ts +5 -0
- package/dist/features/board/HighlightChessboard.d.ts +2 -0
- package/dist/features/board/LineBoard.d.ts +15 -0
- package/dist/features/board/LineBoardWithControls.d.ts +50 -0
- package/dist/features/board/PuzzleBoard.d.ts +10 -0
- package/dist/features/board/PuzzleBoardWithControls.d.ts +51 -0
- package/dist/features/board/PuzzlePlaySurface.d.ts +25 -0
- package/dist/features/board/boardSquareHighlightColors.d.ts +1 -0
- package/dist/features/board/chessboardTheme.d.ts +2 -0
- package/dist/features/board/defaults/DefaultLineControls.d.ts +5 -0
- package/dist/features/board/defaults/DefaultPuzzleControls.d.ts +18 -0
- package/dist/features/board/defaults/index.d.ts +1 -0
- package/dist/features/board/puzzleBoardLayout.d.ts +8 -0
- package/dist/features/engine/EngineEvaluationPanel.d.ts +8 -0
- package/dist/features/engine/StockfishBrowserEngine.d.ts +1 -0
- package/dist/features/engine/formatEvaluation.d.ts +1 -0
- package/dist/features/engine/index.d.ts +1 -0
- package/dist/features/engine/isAnalyzableFen.d.ts +1 -0
- package/dist/features/engine/parseUciInfo.d.ts +1 -0
- package/dist/features/engine/stockfishUrls.d.ts +1 -0
- package/dist/features/engine/types.d.ts +1 -0
- package/dist/features/engine/useAnalysisEngine.d.ts +1 -0
- package/dist/features/position/Position.d.ts +67 -0
- package/dist/features/position/Position.test.d.ts +1 -0
- package/dist/features/position/Traversable.d.ts +4 -0
- package/dist/features/position/moveHistory.d.ts +7 -0
- package/dist/features/theme/ThemeContext.d.ts +26 -0
- package/dist/features/theme/ThemeProvider.d.ts +7 -0
- package/dist/features/theme/squareHighlightColors.d.ts +50 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.esm.js +861 -0
- package/dist/index.js +885 -0
- package/dist/stories/AnalysisBoard.stories.d.ts +9 -0
- package/dist/stories/HighlightChessboard.stories.d.ts +9 -0
- package/dist/stories/PuzzleBoard.stories.d.ts +6 -0
- package/dist/stories/PuzzleBoardWithControls.stories.d.ts +9 -0
- package/dist/stories/analysisFixtures.d.ts +4 -0
- package/dist/stories/regressions/PuzzleRegressions.stories.d.ts +7 -0
- package/dist/stories/regressions/StockfishAnalysisRegressions.stories.d.ts +7 -0
- package/dist/stories/regressions/fixtures.d.ts +18 -0
- package/dist/stories/regressions/regressionAnalysisContext.d.ts +5 -0
- package/dist/stories/storybookLayout.d.ts +4 -0
- package/dist/stories/withChessboardDnD.d.ts +4 -0
- package/dist/stories/withDefaultPuzzleControls.d.ts +7 -0
- package/dist/stories/withThemeProvider.d.ts +3 -0
- package/package.json +87 -0
|
@@ -0,0 +1,861 @@
|
|
|
1
|
+
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
2
|
+
import { jsx, jsxs } from 'react/jsx-runtime';
|
|
3
|
+
import { ChessboardDnDProvider } from 'react-chessboard';
|
|
4
|
+
import { HighlightChessboard, ThemeProvider, AnalysisErrorBoundary, AnalysisBoardCore, AnalysisBoardLayout, AnalysisBoard, DEFAULT_ANALYSIS_LAYOUT, boardSquareHighlightColors, analysisBoardHighlightColors } from 'react-chess-core';
|
|
5
|
+
export { DEFAULT_ANALYSIS_LAYOUT } from 'react-chess-core';
|
|
6
|
+
import { Chess } from 'chess.js';
|
|
7
|
+
|
|
8
|
+
const emptyAnalysisContext = () => ({
|
|
9
|
+
initialFen: '',
|
|
10
|
+
solutionMoves: [],
|
|
11
|
+
currentPly: 0,
|
|
12
|
+
boardOrientation: 'white',
|
|
13
|
+
});
|
|
14
|
+
const buildAnalysisContext = (position) => {
|
|
15
|
+
if (!position) {
|
|
16
|
+
return emptyAnalysisContext();
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
initialFen: position.getInitialFen(),
|
|
20
|
+
solutionMoves: position.getSolutionMoves(),
|
|
21
|
+
currentPly: position.getIndex(),
|
|
22
|
+
boardOrientation: position.getPlayerColor(),
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
const isAnalysisAvailable = (position, resultStatus) => {
|
|
26
|
+
if (!position) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
return (buildAnalysisContext(position).solutionMoves.length > 0 &&
|
|
30
|
+
resultStatus !== 'none');
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const usePuzzleAnalysis = (position, resultStatus, puzzleNum) => {
|
|
34
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
35
|
+
const [snapshot, setSnapshot] = useState(null);
|
|
36
|
+
const canOpen = isAnalysisAvailable(position, resultStatus);
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
setIsOpen(false);
|
|
39
|
+
setSnapshot(null);
|
|
40
|
+
}, [puzzleNum]);
|
|
41
|
+
const openAnalysis = () => {
|
|
42
|
+
if (!canOpen || !position) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
setSnapshot(buildAnalysisContext(position));
|
|
46
|
+
setIsOpen(true);
|
|
47
|
+
};
|
|
48
|
+
const closeAnalysis = () => {
|
|
49
|
+
setIsOpen(false);
|
|
50
|
+
};
|
|
51
|
+
return {
|
|
52
|
+
canOpen,
|
|
53
|
+
isOpen,
|
|
54
|
+
snapshot,
|
|
55
|
+
openAnalysis,
|
|
56
|
+
closeAnalysis,
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const EMPTY_BOARD_FEN = '8/8/8/8/8/8/8/8 w - - 0 1';
|
|
61
|
+
/**
|
|
62
|
+
* Single mounted board for puzzle play. Shows an empty board while the next
|
|
63
|
+
* position loads so the layout and controls do not flicker between cards.
|
|
64
|
+
*/
|
|
65
|
+
const PuzzlePlaySurface = ({ position, onFeedback, incInteractionNum, boardWidth, revealAnswerOnIncorrect = false, }) => {
|
|
66
|
+
var _a, _b, _c, _d, _e;
|
|
67
|
+
const onPieceDrop = (sourceSquare, targetSquare, piece) => {
|
|
68
|
+
if (!position || position.isSolutionRevealed()) {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
if (!position.isLegalMove(sourceSquare, targetSquare)) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
const guess = position.tryGuess(sourceSquare, targetSquare, piece);
|
|
75
|
+
if (!guess.accepted) {
|
|
76
|
+
onFeedback({
|
|
77
|
+
index: position.getIndex(),
|
|
78
|
+
guess: { sourceSquare, targetSquare, piece },
|
|
79
|
+
isCorrect: false,
|
|
80
|
+
});
|
|
81
|
+
incInteractionNum();
|
|
82
|
+
setTimeout(() => {
|
|
83
|
+
if (revealAnswerOnIncorrect) {
|
|
84
|
+
position.resetInteractions();
|
|
85
|
+
position.revealCorrectMove();
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
position.resetInteractions();
|
|
89
|
+
}
|
|
90
|
+
incInteractionNum();
|
|
91
|
+
}, 500);
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
onFeedback({
|
|
95
|
+
index: position.getIndex(),
|
|
96
|
+
guess: { sourceSquare, targetSquare, piece },
|
|
97
|
+
isCorrect: true,
|
|
98
|
+
isFinished: guess.finished,
|
|
99
|
+
});
|
|
100
|
+
incInteractionNum();
|
|
101
|
+
setTimeout(() => {
|
|
102
|
+
position.resetInteractions();
|
|
103
|
+
incInteractionNum();
|
|
104
|
+
}, 500);
|
|
105
|
+
if (position.isAlternativeCheckmate()) {
|
|
106
|
+
incInteractionNum();
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
position.next();
|
|
110
|
+
incInteractionNum();
|
|
111
|
+
setTimeout(() => {
|
|
112
|
+
if (!position.isFinished()) {
|
|
113
|
+
position.next();
|
|
114
|
+
}
|
|
115
|
+
incInteractionNum();
|
|
116
|
+
}, 500);
|
|
117
|
+
return true;
|
|
118
|
+
};
|
|
119
|
+
return (jsx(ChessboardDnDProvider, { children: jsx(HighlightChessboard, { boardWidth: boardWidth, checkSquare: (_a = position === null || position === void 0 ? void 0 : position.getCheckSquare()) !== null && _a !== void 0 ? _a : '', hintSquare: (_b = position === null || position === void 0 ? void 0 : position.getHintSquare()) !== null && _b !== void 0 ? _b : null, incorrectMoveSquare: (_c = position === null || position === void 0 ? void 0 : position.getIncorrectMoveSquare()) !== null && _c !== void 0 ? _c : null, onPieceDrop: onPieceDrop, position: (_d = position === null || position === void 0 ? void 0 : position.fen()) !== null && _d !== void 0 ? _d : EMPTY_BOARD_FEN, boardOrientation: (_e = position === null || position === void 0 ? void 0 : position.getPlayerColor()) !== null && _e !== void 0 ? _e : 'white', arePiecesDraggable: position !== null, promotionDialogVariant: "modal" }) }));
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const PuzzleBoard = ({ position, onFeedback, incInteractionNum, boardWidth, revealAnswerOnIncorrect = false, }) => (jsx(PuzzlePlaySurface, { position: position, onFeedback: onFeedback, incInteractionNum: incInteractionNum, boardWidth: boardWidth, revealAnswerOnIncorrect: revealAnswerOnIncorrect }));
|
|
123
|
+
|
|
124
|
+
const isAttemptFinished = (resultStatus) => resultStatus === 'complete' || resultStatus === 'incorrect';
|
|
125
|
+
/** Library default hint / next / analysis / result controls (unstyled buttons). */
|
|
126
|
+
const DefaultPuzzleControls = ({ showHint, showSolution, nextPuzzle, resultStatus, analysis, controlState, }) => (jsxs("div", { style: rowStyle$1, children: [jsx("button", { type: "button", onClick: showHint, style: buttonStyle, disabled: !controlState.canShowHint, children: "Hint" }), jsx("button", { type: "button", onClick: showSolution, style: buttonStyle, disabled: !controlState.canShowSolution, children: "Show solution" }), jsx("button", { type: "button", onClick: nextPuzzle, style: buttonStyle, children: "Next puzzle" }), analysis.visible && isAttemptFinished(resultStatus) && (jsx("button", { type: "button", onClick: analysis.openAnalysis, style: buttonStyle, children: "Analysis" })), resultStatus === 'complete' && (jsx("span", { style: statusStyle$1, children: "Complete" })), resultStatus === 'incorrect' && (jsx("span", { style: Object.assign(Object.assign({}, statusStyle$1), { color: '#c62828' }), children: "Incorrect" }))] }));
|
|
127
|
+
const defaultRenderControls = (showHint, showSolution, nextPuzzle, resultStatus, analysis, controlState) => (jsx(DefaultPuzzleControls, { showHint: showHint, showSolution: showSolution, nextPuzzle: nextPuzzle, resultStatus: resultStatus, analysis: analysis, controlState: controlState }));
|
|
128
|
+
const rowStyle$1 = {
|
|
129
|
+
display: 'flex',
|
|
130
|
+
flexWrap: 'wrap',
|
|
131
|
+
alignItems: 'center',
|
|
132
|
+
gap: 8,
|
|
133
|
+
width: '100%',
|
|
134
|
+
minHeight: 96,
|
|
135
|
+
alignContent: 'flex-start',
|
|
136
|
+
};
|
|
137
|
+
const buttonStyle = {
|
|
138
|
+
cursor: 'pointer',
|
|
139
|
+
padding: '6px 12px',
|
|
140
|
+
fontSize: 14,
|
|
141
|
+
borderRadius: 4,
|
|
142
|
+
border: '1px solid #ccc',
|
|
143
|
+
background: '#f5f5f5',
|
|
144
|
+
};
|
|
145
|
+
const statusStyle$1 = {
|
|
146
|
+
fontSize: 14,
|
|
147
|
+
fontWeight: 600,
|
|
148
|
+
color: '#2e7d32',
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/** Default pixel width for the live puzzle board (analysis uses {@link DEFAULT_ANALYSIS_LAYOUT}). */
|
|
152
|
+
const DEFAULT_PUZZLE_BOARD_WIDTH = 480;
|
|
153
|
+
/** Minimum height reserved below the board so controls do not shift when status changes. */
|
|
154
|
+
const PUZZLE_CONTROLS_MIN_HEIGHT = 96;
|
|
155
|
+
const puzzlePlayColumnStyle = (boardWidth) => ({
|
|
156
|
+
display: 'flex',
|
|
157
|
+
flexDirection: 'column',
|
|
158
|
+
width: boardWidth,
|
|
159
|
+
maxWidth: '100%',
|
|
160
|
+
});
|
|
161
|
+
const puzzleBoardSlotStyle = () => ({
|
|
162
|
+
width: '100%',
|
|
163
|
+
aspectRatio: '1 / 1',
|
|
164
|
+
flexShrink: 0,
|
|
165
|
+
});
|
|
166
|
+
const puzzleControlsSlotStyle = () => ({
|
|
167
|
+
minHeight: PUZZLE_CONTROLS_MIN_HEIGHT,
|
|
168
|
+
flexShrink: 0,
|
|
169
|
+
display: 'flex',
|
|
170
|
+
alignItems: 'flex-start',
|
|
171
|
+
marginTop: 8,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
/** Apply a UCI move (e.g. `e7e8q`) without throwing. */
|
|
175
|
+
function applyUciMove(chess, uci) {
|
|
176
|
+
if (!uci || uci.length < 4) {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
const from = uci.slice(0, 2);
|
|
180
|
+
const to = uci.slice(2, 4);
|
|
181
|
+
const promotion = uci.length > 4 ? uci[4] : undefined;
|
|
182
|
+
try {
|
|
183
|
+
return chess.move({ from, to, promotion }) !== null;
|
|
184
|
+
}
|
|
185
|
+
catch (_a) {
|
|
186
|
+
try {
|
|
187
|
+
chess.move(uci);
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
catch (_b) {
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
class Position {
|
|
196
|
+
constructor() {
|
|
197
|
+
this.i = 0;
|
|
198
|
+
this.chess = new Chess();
|
|
199
|
+
this.moves = [];
|
|
200
|
+
}
|
|
201
|
+
getIndex() {
|
|
202
|
+
return this.i;
|
|
203
|
+
}
|
|
204
|
+
// Common methods shared by all positions
|
|
205
|
+
next() {
|
|
206
|
+
if (this.i >= this.moves.length) {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
const uci = this.moves[this.i];
|
|
210
|
+
if (!applyUciMove(this.chess, uci)) {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
this.i++;
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
prev() {
|
|
217
|
+
if (this.i > 0) {
|
|
218
|
+
this.chess.undo();
|
|
219
|
+
this.i--;
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
isFinished() {
|
|
225
|
+
return this.i >= this.moves.length;
|
|
226
|
+
}
|
|
227
|
+
/** True when a correct guess at the current index completes the puzzle. */
|
|
228
|
+
isCompletedByCorrectGuess() {
|
|
229
|
+
return this.i >= this.moves.length - 2;
|
|
230
|
+
}
|
|
231
|
+
fen() {
|
|
232
|
+
return this.chess.fen();
|
|
233
|
+
}
|
|
234
|
+
getCheckSquare() {
|
|
235
|
+
return getCheckSquareFromChess(this.chess);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
function getCheckSquareFromChess(chess) {
|
|
239
|
+
if (!chess.inCheck()) {
|
|
240
|
+
return '';
|
|
241
|
+
}
|
|
242
|
+
const turn = chess.turn();
|
|
243
|
+
const kingPieceType = 'k';
|
|
244
|
+
const squaresWithPieces = chess.board().flatMap((row, rowIndex) => row.map((piece, colIndex) => ({
|
|
245
|
+
square: String.fromCharCode(97 + colIndex) + (8 - rowIndex),
|
|
246
|
+
piece: piece,
|
|
247
|
+
})));
|
|
248
|
+
const kingSquare = squaresWithPieces
|
|
249
|
+
.filter(({ piece }) => piece && piece.type === kingPieceType && piece.color === turn)
|
|
250
|
+
.map(({ square }) => square)[0];
|
|
251
|
+
return kingSquare !== null && kingSquare !== void 0 ? kingSquare : '';
|
|
252
|
+
}
|
|
253
|
+
/** Side to move for the final (user) ply in a puzzle line. */
|
|
254
|
+
function playerColorForSolution(initialFen, moves) {
|
|
255
|
+
const chess = new Chess(initialFen);
|
|
256
|
+
const setupPlies = Math.max(0, moves.length - 1);
|
|
257
|
+
for (let j = 0; j < setupPlies; j++) {
|
|
258
|
+
applyUciMove(chess, moves[j]);
|
|
259
|
+
}
|
|
260
|
+
return chess.turn() === 'w' ? 'white' : 'black';
|
|
261
|
+
}
|
|
262
|
+
class PuzzlePosition extends Position {
|
|
263
|
+
constructor(initialFEN, moves) {
|
|
264
|
+
super();
|
|
265
|
+
this.isCorrect = false;
|
|
266
|
+
this.guessedMove = '';
|
|
267
|
+
this.isHintWanted = false;
|
|
268
|
+
this.solutionRevealed = false;
|
|
269
|
+
this.moveHistory = [];
|
|
270
|
+
this.usedAlternativeCheckmate = false;
|
|
271
|
+
this.tryGuess = (sourceSquare, targetSquare, piece) => {
|
|
272
|
+
var _a;
|
|
273
|
+
const candidates = this.buildCandidateUcis(sourceSquare, targetSquare);
|
|
274
|
+
if (candidates.length === 0) {
|
|
275
|
+
this.isCorrect = false;
|
|
276
|
+
this.guessedMove = `${sourceSquare}${targetSquare}`;
|
|
277
|
+
return { accepted: false, finished: false };
|
|
278
|
+
}
|
|
279
|
+
for (const uci of candidates) {
|
|
280
|
+
if (this.moves[this.i] === uci) {
|
|
281
|
+
this.isCorrect = true;
|
|
282
|
+
this.guessedMove = uci.slice(0, 4);
|
|
283
|
+
return {
|
|
284
|
+
accepted: true,
|
|
285
|
+
finished: this.isCompletedByCorrectGuess(),
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
for (const uci of candidates) {
|
|
290
|
+
if (this.isCheckmatingMove(uci) && this.applyCheckmateGuess(uci)) {
|
|
291
|
+
this.isCorrect = true;
|
|
292
|
+
return { accepted: true, finished: true };
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
this.isCorrect = false;
|
|
296
|
+
this.guessedMove = `${sourceSquare}${targetSquare}`;
|
|
297
|
+
this.moveHistory.push({
|
|
298
|
+
ply: this.moveHistory.length,
|
|
299
|
+
uci: (_a = candidates[candidates.length - 1]) !== null && _a !== void 0 ? _a : this.guessedMove,
|
|
300
|
+
actor: 'attempt',
|
|
301
|
+
isCorrect: false,
|
|
302
|
+
});
|
|
303
|
+
return { accepted: false, finished: false };
|
|
304
|
+
};
|
|
305
|
+
this.judgeGuess = (sourceSquare, targetSquare, piece) => this.tryGuess(sourceSquare, targetSquare, piece).accepted;
|
|
306
|
+
this.initialFen = initialFEN;
|
|
307
|
+
this.chess.load(initialFEN);
|
|
308
|
+
this.moves = moves;
|
|
309
|
+
this.playerColor = playerColorForSolution(initialFEN, moves);
|
|
310
|
+
}
|
|
311
|
+
next() {
|
|
312
|
+
if (this.i >= this.moves.length) {
|
|
313
|
+
return false;
|
|
314
|
+
}
|
|
315
|
+
const moveUci = this.moves[this.i];
|
|
316
|
+
const movingColor = this.chess.turn() === 'w' ? 'white' : 'black';
|
|
317
|
+
const result = super.next();
|
|
318
|
+
if (result) {
|
|
319
|
+
const san = this.chess.history().at(-1);
|
|
320
|
+
this.moveHistory.push({
|
|
321
|
+
ply: this.moveHistory.length,
|
|
322
|
+
uci: moveUci,
|
|
323
|
+
san,
|
|
324
|
+
actor: movingColor === this.playerColor ? 'player' : 'opponent',
|
|
325
|
+
isCorrect: true,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
return result;
|
|
329
|
+
}
|
|
330
|
+
getInitialFen() {
|
|
331
|
+
return this.initialFen;
|
|
332
|
+
}
|
|
333
|
+
getSolutionMoves() {
|
|
334
|
+
return [...this.moves];
|
|
335
|
+
}
|
|
336
|
+
getMoveHistory() {
|
|
337
|
+
return [...this.moveHistory];
|
|
338
|
+
}
|
|
339
|
+
recordHint() {
|
|
340
|
+
var _a;
|
|
341
|
+
this.moveHistory.push({
|
|
342
|
+
ply: this.moveHistory.length,
|
|
343
|
+
uci: (_a = this.moves[this.i]) !== null && _a !== void 0 ? _a : '',
|
|
344
|
+
san: 'Hint',
|
|
345
|
+
actor: 'attempt',
|
|
346
|
+
isCorrect: false,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
recordSolutionShown() {
|
|
350
|
+
this.moveHistory.push({
|
|
351
|
+
ply: this.moveHistory.length,
|
|
352
|
+
uci: '',
|
|
353
|
+
san: 'Solution',
|
|
354
|
+
actor: 'attempt',
|
|
355
|
+
isCorrect: false,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
setSolutionRevealed(revealed) {
|
|
359
|
+
this.solutionRevealed = revealed;
|
|
360
|
+
}
|
|
361
|
+
isSolutionRevealed() {
|
|
362
|
+
return this.solutionRevealed;
|
|
363
|
+
}
|
|
364
|
+
/** Reset the board to replay an already-revealed solution walkthrough. */
|
|
365
|
+
replaySolution() {
|
|
366
|
+
this.chess.load(this.initialFen);
|
|
367
|
+
this.i = 0;
|
|
368
|
+
this.isCorrect = false;
|
|
369
|
+
this.guessedMove = '';
|
|
370
|
+
this.isHintWanted = false;
|
|
371
|
+
this.usedAlternativeCheckmate = false;
|
|
372
|
+
this.solutionRevealed = true;
|
|
373
|
+
const solutionIdx = this.moveHistory.findIndex((m) => m.san === 'Solution');
|
|
374
|
+
if (solutionIdx >= 0) {
|
|
375
|
+
this.moveHistory = this.moveHistory.slice(0, solutionIdx + 1);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
/** Chess-legal move from the current puzzle position (ignores solution line). */
|
|
379
|
+
isLegalMove(sourceSquare, targetSquare) {
|
|
380
|
+
if (sourceSquare === targetSquare) {
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
return this.chess
|
|
384
|
+
.moves({ square: sourceSquare, verbose: true })
|
|
385
|
+
.some((move) => move.to === targetSquare);
|
|
386
|
+
}
|
|
387
|
+
buildCandidateUcis(sourceSquare, targetSquare) {
|
|
388
|
+
const lanMoves = this.chess
|
|
389
|
+
.moves({ square: sourceSquare, verbose: true })
|
|
390
|
+
.filter((move) => move.to === targetSquare)
|
|
391
|
+
.map((move) => move.lan);
|
|
392
|
+
return [...new Set(lanMoves)];
|
|
393
|
+
}
|
|
394
|
+
tryMakeUciMove(chess, uci) {
|
|
395
|
+
var _a;
|
|
396
|
+
if (!applyUciMove(chess, uci)) {
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
return (_a = chess.history({ verbose: true }).at(-1)) !== null && _a !== void 0 ? _a : null;
|
|
400
|
+
}
|
|
401
|
+
isCheckmatingMove(uci) {
|
|
402
|
+
const chess = new Chess(this.chess.fen());
|
|
403
|
+
return (this.tryMakeUciMove(chess, uci) !== null && chess.isCheckmate());
|
|
404
|
+
}
|
|
405
|
+
applyCheckmateGuess(uci) {
|
|
406
|
+
const result = this.tryMakeUciMove(this.chess, uci);
|
|
407
|
+
if (result === null || !this.chess.isCheckmate()) {
|
|
408
|
+
return false;
|
|
409
|
+
}
|
|
410
|
+
this.moveHistory.push({
|
|
411
|
+
ply: this.moveHistory.length,
|
|
412
|
+
uci,
|
|
413
|
+
san: result.san,
|
|
414
|
+
actor: 'player',
|
|
415
|
+
isCorrect: true,
|
|
416
|
+
});
|
|
417
|
+
this.i = this.moves.length;
|
|
418
|
+
this.guessedMove = uci.slice(0, 4);
|
|
419
|
+
this.usedAlternativeCheckmate = true;
|
|
420
|
+
return true;
|
|
421
|
+
}
|
|
422
|
+
resetInteractions() {
|
|
423
|
+
this.guessedMove = '';
|
|
424
|
+
this.isHintWanted = false;
|
|
425
|
+
}
|
|
426
|
+
/** Play the expected move at the current index and lock further input. */
|
|
427
|
+
revealCorrectMove() {
|
|
428
|
+
if (this.solutionRevealed || this.i >= this.moves.length) {
|
|
429
|
+
return false;
|
|
430
|
+
}
|
|
431
|
+
const played = this.next();
|
|
432
|
+
if (played) {
|
|
433
|
+
this.solutionRevealed = true;
|
|
434
|
+
}
|
|
435
|
+
return played;
|
|
436
|
+
}
|
|
437
|
+
/** True when the last correct guess applied an alternative checkmate. */
|
|
438
|
+
isAlternativeCheckmate() {
|
|
439
|
+
return this.usedAlternativeCheckmate;
|
|
440
|
+
}
|
|
441
|
+
wantsHint(wants) {
|
|
442
|
+
this.isHintWanted = wants;
|
|
443
|
+
}
|
|
444
|
+
getHintSquare() {
|
|
445
|
+
if (!this.isHintWanted) {
|
|
446
|
+
return '';
|
|
447
|
+
}
|
|
448
|
+
return this.hint().slice(0, 2);
|
|
449
|
+
}
|
|
450
|
+
hint() {
|
|
451
|
+
return this.moves[this.i];
|
|
452
|
+
}
|
|
453
|
+
getIncorrectMoveSquare() {
|
|
454
|
+
if (this.isCorrect) {
|
|
455
|
+
return '';
|
|
456
|
+
}
|
|
457
|
+
return this.guessedMove.slice(2, 4);
|
|
458
|
+
}
|
|
459
|
+
getPlayerColor() {
|
|
460
|
+
return this.playerColor;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
class GamePosition extends Position {
|
|
464
|
+
constructor(PGN) {
|
|
465
|
+
super();
|
|
466
|
+
console.log(`pgn: ${PGN}`);
|
|
467
|
+
this.chess.loadPgn(PGN);
|
|
468
|
+
this.moves = this.chess.history();
|
|
469
|
+
this.chess.reset();
|
|
470
|
+
// console.log(`pgn: ${PGN}`);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** Delay before auto-playing the opponent's opening move (ms). */
|
|
475
|
+
const OPPONENT_OPENING_MOVE_DELAY_MS = 500;
|
|
476
|
+
/** Brief pause so the user sees a correct result before the next card loads. */
|
|
477
|
+
const AUTO_ADVANCE_ON_COMPLETE_DELAY_MS = 700;
|
|
478
|
+
const SOLUTION_STEP_MS = 500;
|
|
479
|
+
const PuzzleBoardWithControls = ({ theme, apiProxy, renderControls = defaultRenderControls, renderAnalysisSidebar, renderAnalysisContainer, renderEngineEvaluation, puzzleBoardWidth = DEFAULT_PUZZLE_BOARD_WIDTH, analysisLayout = DEFAULT_ANALYSIS_LAYOUT, renderAnalysisMain, engine, autoAdvanceOnComplete = false, revealAnswerOnIncorrect = false, }) => {
|
|
480
|
+
const { onFetch, onFetchError, onFeedback } = apiProxy;
|
|
481
|
+
const [position, setPosition] = useState(null);
|
|
482
|
+
const [puzzleNum, setPuzzleNum] = useState(0);
|
|
483
|
+
const [hasIncorrectAttempt, setHasIncorrectAttempt] = useState(false);
|
|
484
|
+
const [puzzleComplete, setPuzzleComplete] = useState(false);
|
|
485
|
+
const [, setInteractionNum] = useState(0);
|
|
486
|
+
const solutionAnimationRef = useRef({ cancelled: false, timeoutIds: [] });
|
|
487
|
+
const incInteractionNum = () => {
|
|
488
|
+
setInteractionNum((prev) => prev + 1);
|
|
489
|
+
};
|
|
490
|
+
const clearSolutionAnimation = () => {
|
|
491
|
+
const anim = solutionAnimationRef.current;
|
|
492
|
+
anim.cancelled = true;
|
|
493
|
+
anim.timeoutIds.forEach(clearTimeout);
|
|
494
|
+
solutionAnimationRef.current = { cancelled: false, timeoutIds: [] };
|
|
495
|
+
};
|
|
496
|
+
useEffect(() => {
|
|
497
|
+
let cancelled = false;
|
|
498
|
+
let openingMoveTimeoutId;
|
|
499
|
+
setPosition(null);
|
|
500
|
+
setHasIncorrectAttempt(false);
|
|
501
|
+
setPuzzleComplete(false);
|
|
502
|
+
onFetch()
|
|
503
|
+
.then((data) => {
|
|
504
|
+
if (cancelled) {
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
if (!data || !data.fen || !data.moves) {
|
|
508
|
+
console.error('Invalid data fetched:', data);
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
const newPosition = new PuzzlePosition(data.fen, data.moves);
|
|
512
|
+
setPosition(newPosition);
|
|
513
|
+
// Multi-move puzzles lead with an opponent setup ply; single-move lines
|
|
514
|
+
// (e.g. a first-ply opening trainer) are already on the player to move.
|
|
515
|
+
if (data.moves.length > 1) {
|
|
516
|
+
openingMoveTimeoutId = setTimeout(() => {
|
|
517
|
+
if (cancelled) {
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
if (newPosition.next()) {
|
|
521
|
+
incInteractionNum();
|
|
522
|
+
}
|
|
523
|
+
}, OPPONENT_OPENING_MOVE_DELAY_MS);
|
|
524
|
+
}
|
|
525
|
+
})
|
|
526
|
+
.catch((error) => {
|
|
527
|
+
if (!cancelled) {
|
|
528
|
+
onFetchError === null || onFetchError === void 0 ? void 0 : onFetchError(error);
|
|
529
|
+
}
|
|
530
|
+
});
|
|
531
|
+
return () => {
|
|
532
|
+
cancelled = true;
|
|
533
|
+
clearSolutionAnimation();
|
|
534
|
+
if (openingMoveTimeoutId !== undefined) {
|
|
535
|
+
clearTimeout(openingMoveTimeoutId);
|
|
536
|
+
}
|
|
537
|
+
};
|
|
538
|
+
}, [puzzleNum]);
|
|
539
|
+
const handleFeedback = (feedbackData) => {
|
|
540
|
+
if (feedbackData.hintRequested ||
|
|
541
|
+
feedbackData.solutionShown ||
|
|
542
|
+
feedbackData.isCorrect === false) {
|
|
543
|
+
setHasIncorrectAttempt(true);
|
|
544
|
+
}
|
|
545
|
+
if (feedbackData.isFinished) {
|
|
546
|
+
setPuzzleComplete(true);
|
|
547
|
+
}
|
|
548
|
+
onFeedback(feedbackData);
|
|
549
|
+
};
|
|
550
|
+
const getResultStatus = () => {
|
|
551
|
+
const finished = puzzleComplete || (position !== null && position.isFinished());
|
|
552
|
+
if (finished) {
|
|
553
|
+
return 'complete';
|
|
554
|
+
}
|
|
555
|
+
if (!position) {
|
|
556
|
+
return 'none';
|
|
557
|
+
}
|
|
558
|
+
if (hasIncorrectAttempt) {
|
|
559
|
+
return 'incorrect';
|
|
560
|
+
}
|
|
561
|
+
return 'none';
|
|
562
|
+
};
|
|
563
|
+
const handleHintRequest = () => {
|
|
564
|
+
if (!position) {
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
position.recordHint();
|
|
568
|
+
handleFeedback({ index: position.getIndex(), hintRequested: true });
|
|
569
|
+
position.wantsHint(true);
|
|
570
|
+
incInteractionNum();
|
|
571
|
+
setTimeout(() => {
|
|
572
|
+
position.resetInteractions();
|
|
573
|
+
incInteractionNum();
|
|
574
|
+
}, 500);
|
|
575
|
+
};
|
|
576
|
+
const runSolutionWalkthrough = (pos, emitFinishFeedback) => {
|
|
577
|
+
clearSolutionAnimation();
|
|
578
|
+
const anim = {
|
|
579
|
+
cancelled: false,
|
|
580
|
+
timeoutIds: [],
|
|
581
|
+
};
|
|
582
|
+
solutionAnimationRef.current = anim;
|
|
583
|
+
const schedule = (fn, ms) => {
|
|
584
|
+
const id = setTimeout(() => {
|
|
585
|
+
if (anim.cancelled) {
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
fn();
|
|
589
|
+
}, ms);
|
|
590
|
+
anim.timeoutIds.push(id);
|
|
591
|
+
};
|
|
592
|
+
const finish = () => {
|
|
593
|
+
setPuzzleComplete(true);
|
|
594
|
+
if (emitFinishFeedback) {
|
|
595
|
+
handleFeedback({
|
|
596
|
+
index: pos.getIndex(),
|
|
597
|
+
isFinished: true,
|
|
598
|
+
isCorrect: false,
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
incInteractionNum();
|
|
602
|
+
};
|
|
603
|
+
const playNextMove = () => {
|
|
604
|
+
if (pos.isFinished()) {
|
|
605
|
+
return false;
|
|
606
|
+
}
|
|
607
|
+
const played = pos.next();
|
|
608
|
+
if (played) {
|
|
609
|
+
incInteractionNum();
|
|
610
|
+
}
|
|
611
|
+
return played;
|
|
612
|
+
};
|
|
613
|
+
const advance = () => {
|
|
614
|
+
if (anim.cancelled || pos.isFinished()) {
|
|
615
|
+
if (pos.isFinished()) {
|
|
616
|
+
finish();
|
|
617
|
+
}
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
if (!playNextMove()) {
|
|
621
|
+
if (pos.isFinished()) {
|
|
622
|
+
finish();
|
|
623
|
+
}
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
schedule(() => {
|
|
627
|
+
if (anim.cancelled) {
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
playNextMove();
|
|
631
|
+
if (pos.isFinished()) {
|
|
632
|
+
finish();
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
schedule(advance, SOLUTION_STEP_MS);
|
|
636
|
+
}, SOLUTION_STEP_MS);
|
|
637
|
+
};
|
|
638
|
+
advance();
|
|
639
|
+
};
|
|
640
|
+
const handleShowSolution = () => {
|
|
641
|
+
if (!position) {
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
if (position.isSolutionRevealed()) {
|
|
645
|
+
position.replaySolution();
|
|
646
|
+
setPuzzleComplete(false);
|
|
647
|
+
incInteractionNum();
|
|
648
|
+
runSolutionWalkthrough(position, false);
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
if (position.isFinished()) {
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
position.recordSolutionShown();
|
|
655
|
+
position.setSolutionRevealed(true);
|
|
656
|
+
position.wantsHint(false);
|
|
657
|
+
handleFeedback({
|
|
658
|
+
index: position.getIndex(),
|
|
659
|
+
solutionShown: true,
|
|
660
|
+
isCorrect: false,
|
|
661
|
+
});
|
|
662
|
+
incInteractionNum();
|
|
663
|
+
runSolutionWalkthrough(position, true);
|
|
664
|
+
};
|
|
665
|
+
const handleNextPuzzle = useCallback(() => {
|
|
666
|
+
setPuzzleNum((prevPuzzleNum) => prevPuzzleNum + 1);
|
|
667
|
+
}, []);
|
|
668
|
+
const resultStatus = getResultStatus();
|
|
669
|
+
useEffect(() => {
|
|
670
|
+
if (!autoAdvanceOnComplete) {
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
if (resultStatus !== 'complete' || hasIncorrectAttempt) {
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
const timer = setTimeout(() => {
|
|
677
|
+
handleNextPuzzle();
|
|
678
|
+
}, AUTO_ADVANCE_ON_COMPLETE_DELAY_MS);
|
|
679
|
+
return () => {
|
|
680
|
+
clearTimeout(timer);
|
|
681
|
+
};
|
|
682
|
+
}, [
|
|
683
|
+
autoAdvanceOnComplete,
|
|
684
|
+
resultStatus,
|
|
685
|
+
hasIncorrectAttempt,
|
|
686
|
+
handleNextPuzzle,
|
|
687
|
+
puzzleNum,
|
|
688
|
+
]);
|
|
689
|
+
const controlState = {
|
|
690
|
+
canShowHint: position !== null &&
|
|
691
|
+
resultStatus === 'none' &&
|
|
692
|
+
!position.isSolutionRevealed(),
|
|
693
|
+
canShowSolution: position !== null &&
|
|
694
|
+
(position.isSolutionRevealed() || !position.isFinished()),
|
|
695
|
+
};
|
|
696
|
+
const analysis = usePuzzleAnalysis(position, resultStatus, puzzleNum);
|
|
697
|
+
const analysisSnapshot = analysis.isOpen && analysis.snapshot ? analysis.snapshot : null;
|
|
698
|
+
const useHostAnalysisUi = Boolean(renderAnalysisSidebar &&
|
|
699
|
+
renderAnalysisContainer &&
|
|
700
|
+
(renderEngineEvaluation || (engine === null || engine === void 0 ? void 0 : engine.enabled) === false));
|
|
701
|
+
return (jsx(ThemeProvider, { theme: theme, children: analysisSnapshot ? (jsx(AnalysisErrorBoundary, { onClose: analysis.closeAnalysis, children: useHostAnalysisUi ? (jsx(AnalysisBoardCore, { analysisContext: analysisSnapshot, onClose: analysis.closeAnalysis, theme: theme, boardWidth: analysisLayout.boardWidth, engine: engine, renderMain: renderAnalysisMain !== null && renderAnalysisMain !== void 0 ? renderAnalysisMain : (({ board, sidebar, model }) => (jsx(AnalysisBoardLayout, { layout: analysisLayout, model: model, board: board, sidebar: sidebar }))), renderSidebar: renderAnalysisSidebar, renderContainer: renderAnalysisContainer, renderEngineEvaluation: renderEngineEvaluation !== null && renderEngineEvaluation !== void 0 ? renderEngineEvaluation : (() => null) })) : (jsx(AnalysisBoard, { analysisContext: analysisSnapshot, onClose: analysis.closeAnalysis, theme: theme, layout: analysisLayout, engine: engine, renderMain: renderAnalysisMain, renderSidebar: renderAnalysisSidebar, renderContainer: renderAnalysisContainer, renderEngineEvaluation: renderEngineEvaluation })) })) : (jsxs("div", { style: puzzlePlayColumnStyle(puzzleBoardWidth), children: [jsx("div", { style: puzzleBoardSlotStyle(), children: jsx(PuzzlePlaySurface, { position: position, boardWidth: puzzleBoardWidth, onFeedback: handleFeedback, incInteractionNum: incInteractionNum, revealAnswerOnIncorrect: revealAnswerOnIncorrect }) }), jsx("div", { style: puzzleControlsSlotStyle(), children: renderControls(handleHintRequest, handleShowSolution, handleNextPuzzle, resultStatus, {
|
|
702
|
+
visible: analysis.canOpen,
|
|
703
|
+
openAnalysis: analysis.openAnalysis,
|
|
704
|
+
}, controlState) })] })) }));
|
|
705
|
+
};
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Board view for line drilling: a plain themed board that only lets the trainer
|
|
709
|
+
* side's pieces be dragged. Move validation and sequencing live in
|
|
710
|
+
* {@link LineBoardWithControls}.
|
|
711
|
+
*/
|
|
712
|
+
const LineBoard = ({ fen, orientation, trainSide, draggable, onPieceDrop, boardWidth, }) => (jsx(ChessboardDnDProvider, { children: jsx(HighlightChessboard, { boardWidth: boardWidth, checkSquare: "", hintSquare: null, incorrectMoveSquare: null, position: fen, boardOrientation: orientation, arePiecesDraggable: draggable, isDraggablePiece: ({ piece }) => piece[0] === trainSide, onPieceDrop: (source, target, piece) => onPieceDrop(source, target, piece), autoPromoteToQueen: true, areArrowsAllowed: false, customBoardStyle: { borderRadius: 4 } }) }));
|
|
713
|
+
|
|
714
|
+
/** Library default line-drill status controls (unstyled). */
|
|
715
|
+
const DefaultLineControls = ({ moveNumber, total, finished, isUserTurn, feedback, }) => (jsxs("div", { style: rowStyle, children: [jsx("span", { style: statusStyle, children: finished ? 'Line complete' : `Move ${moveNumber} of ${total}` }), feedback && !finished && (jsx("span", { style: Object.assign(Object.assign({}, statusStyle), { color: feedback.isCorrect ? '#2e7d32' : '#c62828' }), children: feedback.isCorrect
|
|
716
|
+
? `Correct: ${feedback.expectedSan}`
|
|
717
|
+
: `Best was ${feedback.expectedSan}` })), isUserTurn && !finished && jsx("span", { style: hintStyle, children: "Your move" })] }));
|
|
718
|
+
const defaultRenderLineControls = (props) => (jsx(DefaultLineControls, Object.assign({}, props)));
|
|
719
|
+
const rowStyle = {
|
|
720
|
+
display: 'flex',
|
|
721
|
+
flexWrap: 'wrap',
|
|
722
|
+
alignItems: 'center',
|
|
723
|
+
gap: 8,
|
|
724
|
+
width: '100%',
|
|
725
|
+
minHeight: 96,
|
|
726
|
+
alignContent: 'flex-start',
|
|
727
|
+
};
|
|
728
|
+
const statusStyle = {
|
|
729
|
+
fontSize: 14,
|
|
730
|
+
fontWeight: 600,
|
|
731
|
+
};
|
|
732
|
+
const hintStyle = {
|
|
733
|
+
fontSize: 13,
|
|
734
|
+
color: '#9e9e9e',
|
|
735
|
+
};
|
|
736
|
+
|
|
737
|
+
const DEFAULT_OPPONENT_MOVE_DELAY_MS = 450;
|
|
738
|
+
const turnFromFen = (fen) => fen.trim().split(/\s+/)[1] === 'b' ? 'b' : 'w';
|
|
739
|
+
const boardOrientationForLine = (side) => side === 'b' ? 'black' : 'white';
|
|
740
|
+
/**
|
|
741
|
+
* Guided line-drill board: walks a known move sequence, auto-playing the
|
|
742
|
+
* opponent and grading each of the trainer's moves against the line. Mirrors
|
|
743
|
+
* {@link PuzzleBoardWithControls} but for opening/line repetition rather than
|
|
744
|
+
* tactical puzzles. Mount one instance per drill (key it by line) so its
|
|
745
|
+
* internal state resets between lines.
|
|
746
|
+
*/
|
|
747
|
+
const LineBoardWithControls = ({ theme, line, onComplete, onMove, renderControls = defaultRenderLineControls, boardWidth = DEFAULT_PUZZLE_BOARD_WIDTH, opponentMoveDelayMs = DEFAULT_OPPONENT_MOVE_DELAY_MS, }) => {
|
|
748
|
+
const chessRef = useRef(new Chess(line.startFen));
|
|
749
|
+
const perMoveRef = useRef([]);
|
|
750
|
+
const completedRef = useRef(false);
|
|
751
|
+
const onCompleteRef = useRef(onComplete);
|
|
752
|
+
onCompleteRef.current = onComplete;
|
|
753
|
+
const onMoveRef = useRef(onMove);
|
|
754
|
+
onMoveRef.current = onMove;
|
|
755
|
+
const [fen, setFen] = useState(line.startFen);
|
|
756
|
+
const [currentIndex, setCurrentIndex] = useState(0);
|
|
757
|
+
const [finished, setFinished] = useState(false);
|
|
758
|
+
const [feedback, setFeedback] = useState(null);
|
|
759
|
+
const total = line.movesUci.length;
|
|
760
|
+
const orientation = boardOrientationForLine(line.trainSide);
|
|
761
|
+
const applyMove = useCallback((index) => {
|
|
762
|
+
const uci = line.movesUci[index];
|
|
763
|
+
if (!uci) {
|
|
764
|
+
setFinished(true);
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
const from = uci.slice(0, 2);
|
|
768
|
+
const to = uci.slice(2, 4);
|
|
769
|
+
const promotion = uci.length > 4 ? uci.slice(4) : undefined;
|
|
770
|
+
try {
|
|
771
|
+
chessRef.current.move({ from, to, promotion });
|
|
772
|
+
}
|
|
773
|
+
catch (_a) {
|
|
774
|
+
// Stored line should always be legal; bail out gracefully if not.
|
|
775
|
+
setFinished(true);
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
setFen(chessRef.current.fen());
|
|
779
|
+
setCurrentIndex(index + 1);
|
|
780
|
+
}, [line.movesUci]);
|
|
781
|
+
// Auto-play opponent moves and detect the end of the line.
|
|
782
|
+
useEffect(() => {
|
|
783
|
+
if (finished) {
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
if (currentIndex >= total) {
|
|
787
|
+
setFinished(true);
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
if (turnFromFen(chessRef.current.fen()) !== line.trainSide) {
|
|
791
|
+
const timer = setTimeout(() => applyMove(currentIndex), opponentMoveDelayMs);
|
|
792
|
+
return () => clearTimeout(timer);
|
|
793
|
+
}
|
|
794
|
+
return undefined;
|
|
795
|
+
}, [
|
|
796
|
+
currentIndex,
|
|
797
|
+
finished,
|
|
798
|
+
total,
|
|
799
|
+
line.trainSide,
|
|
800
|
+
applyMove,
|
|
801
|
+
opponentMoveDelayMs,
|
|
802
|
+
]);
|
|
803
|
+
// Emit the completion event exactly once.
|
|
804
|
+
useEffect(() => {
|
|
805
|
+
if (!finished || completedRef.current) {
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
completedRef.current = true;
|
|
809
|
+
onCompleteRef.current(perMoveRef.current);
|
|
810
|
+
}, [finished]);
|
|
811
|
+
const handleDrop = (source, target) => {
|
|
812
|
+
var _a, _b, _c, _d;
|
|
813
|
+
if (finished) {
|
|
814
|
+
return false;
|
|
815
|
+
}
|
|
816
|
+
if (turnFromFen(chessRef.current.fen()) !== line.trainSide) {
|
|
817
|
+
return false;
|
|
818
|
+
}
|
|
819
|
+
const index = currentIndex;
|
|
820
|
+
let userUci;
|
|
821
|
+
try {
|
|
822
|
+
const probe = new Chess(chessRef.current.fen());
|
|
823
|
+
const move = probe.move({ from: source, to: target, promotion: 'q' });
|
|
824
|
+
if (!move) {
|
|
825
|
+
return false;
|
|
826
|
+
}
|
|
827
|
+
userUci = `${move.from}${move.to}${(_a = move.promotion) !== null && _a !== void 0 ? _a : ''}`;
|
|
828
|
+
}
|
|
829
|
+
catch (_e) {
|
|
830
|
+
return false;
|
|
831
|
+
}
|
|
832
|
+
const expected = line.movesUci[index];
|
|
833
|
+
const isCorrect = userUci.toLowerCase() === expected.toLowerCase();
|
|
834
|
+
const expectedSan = (_c = (_b = line.movesSan) === null || _b === void 0 ? void 0 : _b[index]) !== null && _c !== void 0 ? _c : expected;
|
|
835
|
+
perMoveRef.current.push({ index, isCorrect });
|
|
836
|
+
const moveFeedback = { index, isCorrect, expectedSan };
|
|
837
|
+
setFeedback(moveFeedback);
|
|
838
|
+
(_d = onMoveRef.current) === null || _d === void 0 ? void 0 : _d.call(onMoveRef, moveFeedback);
|
|
839
|
+
applyMove(index);
|
|
840
|
+
return isCorrect;
|
|
841
|
+
};
|
|
842
|
+
const moveNumber = Math.min(currentIndex + 1, total);
|
|
843
|
+
const isUserTurn = !finished && turnFromFen(fen) === line.trainSide && currentIndex < total;
|
|
844
|
+
return (jsx(ThemeProvider, { theme: theme, children: jsxs("div", { style: puzzlePlayColumnStyle(boardWidth), children: [jsx("div", { style: puzzleBoardSlotStyle(), children: jsx(LineBoard, { fen: fen, orientation: orientation, trainSide: line.trainSide, draggable: isUserTurn, onPieceDrop: handleDrop, boardWidth: boardWidth }) }), jsx("div", { style: puzzleControlsSlotStyle(), children: renderControls({
|
|
845
|
+
trainSide: line.trainSide,
|
|
846
|
+
moveNumber,
|
|
847
|
+
total,
|
|
848
|
+
finished,
|
|
849
|
+
isUserTurn,
|
|
850
|
+
feedback,
|
|
851
|
+
}) })] }) }));
|
|
852
|
+
};
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* Puzzle-kit public API — puzzle play and post-puzzle analysis glue.
|
|
856
|
+
* Board theme, analysis UI, and Stockfish: import from `react-chess-core`.
|
|
857
|
+
*/
|
|
858
|
+
/** @deprecated Import {@link boardSquareHighlightColors} and {@link analysisBoardHighlightColors} from `react-chess-core`. */
|
|
859
|
+
const squareHighlightColors = Object.assign(Object.assign({}, boardSquareHighlightColors), analysisBoardHighlightColors);
|
|
860
|
+
|
|
861
|
+
export { DEFAULT_PUZZLE_BOARD_WIDTH, DefaultLineControls, DefaultPuzzleControls, GamePosition, LineBoard, LineBoardWithControls, Position, PuzzleBoard, PuzzleBoardWithControls, PuzzlePosition, applyUciMove, buildAnalysisContext, defaultRenderControls, defaultRenderLineControls, emptyAnalysisContext, getCheckSquareFromChess, isAnalysisAvailable, playerColorForSolution, squareHighlightColors, usePuzzleAnalysis };
|