@connectorvol/chess-widgets 9.0.0 → 9.0.2

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.
@@ -1,955 +1,991 @@
1
1
  <script lang="ts" module>
2
- export type {
3
- TPuzzlePgnBoardTreeEditorOutcome,
4
- TPuzzlePgnBoardTreeEditorProps,
5
- } from "./types.js";
2
+ export type {
3
+ TPuzzlePgnBoardTreeEditorOutcome,
4
+ TPuzzlePgnBoardTreeEditorProps,
5
+ } from "./types.js";
6
6
  </script>
7
7
 
8
8
  <script lang="ts">
9
- import type { IChessBoardActions } from "@connectorvol/chessboard";
10
- import type { Square } from "@connectorvol/shared";
11
-
12
- import {
13
- CHESSBOARD_THEMES,
14
- createBoardApi,
15
- Draggable,
16
- syncMoveEvaluationNagBadge,
17
- } from "@connectorvol/chessboard";
18
- import {
19
- ChessTree,
20
- transformPgnToChessNode,
21
- } from "@connectorvol/tree";
22
- import { untrack } from "svelte";
23
-
24
- import { INITIAL_FEN } from "@connectorvol/chessops/fen";
25
- import { PgnOps } from "@connectorvol/chessops/pgnOps.svelte";
26
- import {
27
- Color,
28
- calculatePly,
29
- type Move,
30
- PUZZLE_BRANCH_CORRECT_NAG_ID,
31
- PUZZLE_BRANCH_WRONG_NAG_ID,
32
- isPuzzleBranchNag,
33
- } from "@connectorvol/shared";
34
-
35
- import { mergePgnWithSetupFen } from "../puzzle/mergePgnWithSetupFen.js";
36
- import {
37
- createEmptyTreeFromFen,
38
- createStudentPreviewTreeFromFen,
39
- } from "../puzzle/puzzleData.js";
40
- import { PREVIEW_WRONG_MOVE_REVEAL_MS, PREVIEW_WRONG_NO_VARIANT_MESSAGE } from "../puzzle/puzzlePreviewConstants.js";
41
- import { normalizePuzzleTreeMainLine, syncPuzzleBranchNagsOnTree } from "../puzzle/syncPuzzleBranchNags.js";
42
- import { applyPlayedSolverNagsAlongPath } from "../puzzle/puzzlePreviewPathNags.js";
43
- import {
44
- puzzlePreviewAddAdHocWrongMoveToSolutionTree,
45
- puzzlePreviewClassifySolverMove,
46
- puzzlePreviewDupSubtreeUnderStudentCursor,
47
- puzzlePreviewIsSolvedPosition,
48
- puzzlePreviewNodeAtPath,
49
- puzzlePreviewRemapPathAfterReorder,
50
- puzzlePreviewSideToMoveFromFen,
51
- type TSolverMoveOutcome,
52
- } from "../puzzle/puzzleStepPreviewSolver.js";
53
- import PuzzleBoardTreeViewerPane from "./PuzzleBoardTreeViewerPane.svelte";
54
- import { cn } from "../utils.js";
55
- import type { TPuzzlePgnBoardTreeEditorProps } from "./types.js";
56
- import { DEFAULT_BOARD_APPEARANCE_SETTINGS } from "../constants/default-board-appearance-settings.js";
57
- import { buildPuzzleWizardBoardSettings } from "./buildPuzzleWizardBoardSettings.js";
58
-
59
- let {
60
- pgn = "",
61
- fen: fenProp,
62
- onOutcome,
63
- boardTheme,
64
- boardAppearanceSettings = DEFAULT_BOARD_APPEARANCE_SETTINGS,
65
- boardSize,
66
- onResizeAction,
67
- class: className,
68
- wrongFeedback = $bindable<string | null>(null),
69
- solved = $bindable(false),
70
- }: TPuzzlePgnBoardTreeEditorProps = $props();
71
-
72
- /** Корень превью: синхронизируется с эффектом смены PGN/FEN. */
73
- let layoutInitialFen = $state(INITIAL_FEN);
74
-
75
- /**
76
- * Представляет цвет стороны решателя по текущему корню превью (`layoutInitialFen`).
77
- */
78
- function puzzleSolverColor(): Color {
79
- return layoutInitialFen.trim().split(/\s+/)[1] === "b"
80
- ? Color.BLACK
81
- : Color.WHITE;
82
- }
83
-
84
- let cursorPath = $state<number[]>([]);
85
-
86
- const OPPONENT_MOVE_ANIMATION_MS = 300;
87
-
88
- let wrongRevealTimer: ReturnType<typeof setTimeout> | null = null;
89
- let advanceLineTimer: ReturnType<typeof setTimeout> | null = null;
90
- let opponentAutoTimer: ReturnType<typeof setTimeout> | null = null;
91
-
92
- /** Представляет отложенный откат ошибочного хода на доске после показа NAG ✗. */
93
- type TPendingWrongMoveReveal = {
94
- /** Возвращает классификацию хода решателя. */
95
- outcome: TSolverMoveOutcome;
96
- /** Возвращает сыгранный SAN. */
97
- playedSan: string;
98
- /** Возвращает FEN позиции до ошибочного хода. */
99
- revertFen: string;
100
- /** Возвращает последний ход до ошибочного хода (для подсветки на доске). */
101
- revertLastMove: Move | null;
102
- };
103
-
104
- let pendingWrongMoveReveal = $state<TPendingWrongMoveReveal | null>(null);
105
-
106
- /** Представляет последний применённый ход решателя до колбэка `afterPieceMove` (drag-and-drop). */
107
- let lastSolverMoveForBoard: Move | null = null;
108
-
109
- let previewChess = new PgnOps(INITIAL_FEN, "chess");
110
-
111
- const studentPreviewTree = new ChessTree(createEmptyTreeFromFen(INITIAL_FEN));
112
-
113
- let solutionPreviewTree = $state<ChessTree | null>(null);
114
-
115
- /**
116
- * Представляет синхронизацию доступности перетаскивания с очередью хода и состоянием «решено».
117
- */
118
- function syncBoardDragFromChessTurn(): void {
119
- if (pendingWrongMoveReveal !== null) {
120
- chessboard.draggable = Draggable.NONE;
121
- return;
9
+ import type { IChessBoardActions } from "@connectorvol/chessboard";
10
+ import type { Square } from "@connectorvol/shared";
11
+
12
+ import {
13
+ createBoardApi,
14
+ Draggable,
15
+ syncMoveEvaluationNagBadge,
16
+ } from "@connectorvol/chessboard";
17
+ import { ChessTree, transformPgnToChessNode } from "@connectorvol/tree";
18
+ import { untrack } from "svelte";
19
+
20
+ import { INITIAL_FEN } from "@connectorvol/chessops/fen";
21
+ import { PgnOps } from "@connectorvol/chessops/pgnOps.svelte";
22
+ import {
23
+ Color,
24
+ calculatePly,
25
+ type Move,
26
+ PUZZLE_BRANCH_CORRECT_NAG_ID,
27
+ PUZZLE_BRANCH_WRONG_NAG_ID,
28
+ isPuzzleBranchNag,
29
+ } from "@connectorvol/shared";
30
+
31
+ import { mergePgnWithSetupFen } from "../puzzle/mergePgnWithSetupFen.js";
32
+ import {
33
+ createEmptyTreeFromFen,
34
+ createStudentPreviewTreeFromFen,
35
+ } from "../puzzle/puzzleData.js";
36
+ import {
37
+ PREVIEW_WRONG_MOVE_REVEAL_MS,
38
+ PREVIEW_WRONG_NO_VARIANT_MESSAGE,
39
+ } from "../puzzle/puzzlePreviewConstants.js";
40
+ import {
41
+ normalizePuzzleTreeMainLine,
42
+ syncPuzzleBranchNagsOnTree,
43
+ } from "../puzzle/syncPuzzleBranchNags.js";
44
+ import { applyPlayedSolverNagsAlongPath } from "../puzzle/puzzlePreviewPathNags.js";
45
+ import {
46
+ puzzlePreviewAddAdHocWrongMoveToSolutionTree,
47
+ puzzlePreviewClassifySolverMove,
48
+ puzzlePreviewDupSubtreeUnderStudentCursor,
49
+ puzzlePreviewIsSolvedPosition,
50
+ puzzlePreviewNodeAtPath,
51
+ puzzlePreviewRemapPathAfterReorder,
52
+ puzzlePreviewSideToMoveFromFen,
53
+ type TSolverMoveOutcome,
54
+ } from "../puzzle/puzzleStepPreviewSolver.js";
55
+ import PuzzleBoardTreeViewerPane from "./PuzzleBoardTreeViewerPane.svelte";
56
+ import { cn } from "../utils.js";
57
+ import type { TPuzzlePgnBoardTreeEditorProps } from "./types.js";
58
+ import { DEFAULT_BOARD_APPEARANCE_SETTINGS } from "../constants/default-board-appearance-settings.js";
59
+ import { buildPuzzleWizardBoardSettings } from "./buildPuzzleWizardBoardSettings.js";
60
+
61
+ let {
62
+ pgn = "",
63
+ fen: fenProp,
64
+ onOutcome,
65
+ boardAppearanceSettings = DEFAULT_BOARD_APPEARANCE_SETTINGS,
66
+ boardSize,
67
+ onResizeAction,
68
+ class: className,
69
+ wrongFeedback = $bindable<string | null>(null),
70
+ solved = $bindable(false),
71
+ }: TPuzzlePgnBoardTreeEditorProps = $props();
72
+
73
+ /** Корень превью: синхронизируется с эффектом смены PGN/FEN. */
74
+ let layoutInitialFen = $state(INITIAL_FEN);
75
+
76
+ /**
77
+ * Представляет цвет стороны решателя по текущему корню превью (`layoutInitialFen`).
78
+ */
79
+ function puzzleSolverColor(): Color {
80
+ return layoutInitialFen.trim().split(/\s+/)[1] === "b"
81
+ ? Color.BLACK
82
+ : Color.WHITE;
122
83
  }
123
84
 
124
- const solver = puzzleSolverColor();
125
- chessboard.draggable = solved
126
- ? previewChess.turn() === Color.WHITE
127
- ? Draggable.WHITE
128
- : Draggable.BLACK
129
- : previewChess.turn() === solver
130
- ? solver === Color.WHITE
131
- ? Draggable.WHITE
132
- : Draggable.BLACK
133
- : Draggable.NONE;
134
- }
135
-
136
- /**
137
- * Представляет отмену отложенного отката ошибочного хода без записи варианта в дерево.
138
- */
139
- function dismissPendingWrongMoveReveal(): void {
140
- if (wrongRevealTimer !== null) {
141
- clearTimeout(wrongRevealTimer);
142
- wrongRevealTimer = null;
143
- }
85
+ let cursorPath = $state<number[]>([]);
144
86
 
145
- const pending = pendingWrongMoveReveal;
146
- if (pending === null) return;
87
+ const OPPONENT_MOVE_ANIMATION_MS = 300;
147
88
 
148
- pendingWrongMoveReveal = null;
149
- previewChess.setFen(pending.revertFen);
150
- chessboard.fen = pending.revertFen;
151
- chessboard.addLastMove(pending.revertLastMove);
152
- syncMoveEvaluationNagBadge(chessboard, {
153
- nags: undefined,
154
- lastMove: pending.revertLastMove,
155
- });
156
- syncBoardDragFromChessTurn();
157
- }
158
-
159
- /**
160
- * Представляет досрочное завершение показа ошибочного хода перед следующим ходом решателя.
161
- */
162
- function cancelPendingWrongMoveReveal(): void {
163
- if (pendingWrongMoveReveal === null) return;
164
-
165
- if (wrongRevealTimer !== null) {
166
- clearTimeout(wrongRevealTimer);
167
- wrongRevealTimer = null;
168
- }
89
+ let wrongRevealTimer: ReturnType<typeof setTimeout> | null = null;
90
+ let advanceLineTimer: ReturnType<typeof setTimeout> | null = null;
91
+ let opponentAutoTimer: ReturnType<typeof setTimeout> | null = null;
169
92
 
170
- finishWrongMoveReveal();
171
- }
93
+ /** Представляет отложенный откат ошибочного хода на доске после показа NAG ✗. */
94
+ type TPendingWrongMoveReveal = {
95
+ /** Возвращает классификацию хода решателя. */
96
+ outcome: TSolverMoveOutcome;
97
+ /** Возвращает сыгранный SAN. */
98
+ playedSan: string;
99
+ /** Возвращает FEN позиции до ошибочного хода. */
100
+ revertFen: string;
101
+ /** Возвращает последний ход до ошибочного хода (для подсветки на доске). */
102
+ revertLastMove: Move | null;
103
+ };
172
104
 
173
- /**
174
- * Представляет показ NAG ✗ на доске и отложенный откат ошибочного хода.
175
- */
176
- function scheduleWrongMoveReveal(wrongMove: Move): void {
177
- const pending = pendingWrongMoveReveal;
178
- if (pending === null) return;
105
+ let pendingWrongMoveReveal = $state<TPendingWrongMoveReveal | null>(null);
179
106
 
180
- syncMoveEvaluationNagBadge(chessboard, {
181
- nags: [PUZZLE_BRANCH_WRONG_NAG_ID],
182
- lastMove: wrongMove,
183
- });
184
- syncBoardDragFromChessTurn();
185
-
186
- wrongRevealTimer = setTimeout(() => {
187
- wrongRevealTimer = null;
188
- finishWrongMoveReveal();
189
- }, PREVIEW_WRONG_MOVE_REVEAL_MS);
190
- }
191
-
192
- /**
193
- * Представляет завершение показа ошибочного хода: запись варианта в дерево и откат позиции на доске.
194
- */
195
- function finishWrongMoveReveal(): void {
196
- const pending = pendingWrongMoveReveal;
197
- pendingWrongMoveReveal = null;
198
- if (pending === null) return;
199
-
200
- previewChess.setFen(pending.revertFen);
201
- handleWrongOutcome(pending.outcome, pending.playedSan);
202
-
203
- chessboard.fen = pending.revertFen;
204
- chessboard.addLastMove(pending.revertLastMove);
205
- syncMoveEvaluationNagBadge(chessboard, {
206
- nags: undefined,
207
- lastMove: pending.revertLastMove,
208
- });
209
- syncBoardDragFromChessTurn();
210
- }
211
-
212
- /**
213
- * Представляет синхронизацию NAG ✓ на доске после верного хода решателя.
214
- */
215
- function syncCorrectSolverMoveNagBadge(move: Move): void {
216
- syncMoveEvaluationNagBadge(chessboard, {
217
- nags: [PUZZLE_BRANCH_CORRECT_NAG_ID],
218
- lastMove: move,
219
- });
220
- }
221
-
222
- /**
223
- * Представляет данные узла для бейджа NAG на доске в режиме свободного анализа (без ✓/✗ задачи).
224
- */
225
- function nodeDataForAnalysisBoardBadge(data: {
226
- nags?: number[];
227
- lastMove?: Move | null;
228
- }): { nags?: number[]; lastMove?: Move | null } {
229
- const nags = data.nags?.filter((n) => !isPuzzleBranchNag(n));
230
- return {
231
- nags: nags && nags.length > 0 ? nags : undefined,
232
- lastMove: data.lastMove ?? null,
233
- };
234
- }
235
-
236
- /**
237
- * Представляет синхронизацию бейджей NAG и маркеров доски с текущим узлом дерева решения (свободный анализ).
238
- */
239
- function syncAnalysisBoardFromCurrentNode(): void {
240
- const tree = solutionPreviewTree;
241
- if (!tree) return;
242
-
243
- syncMoveEvaluationNagBadge(
244
- chessboard,
245
- nodeDataForAnalysisBoardBadge(tree.currentNode.data),
107
+ /** Представляет последний применённый ход решателя до колбэка `afterPieceMove` (drag-and-drop). */
108
+ let lastSolverMoveForBoard: Move | null = null;
109
+
110
+ let previewChess = new PgnOps(INITIAL_FEN, "chess");
111
+
112
+ const studentPreviewTree = new ChessTree(
113
+ createEmptyTreeFromFen(INITIAL_FEN),
246
114
  );
247
- chessboard.setNodeMarkers(tree.currentNode.data.parsedFirstComment?.shapes);
248
- syncBoardDragFromChessTurn();
249
- }
250
-
251
- /**
252
- * Представляет обработку UI после применения хода решателя на доске.
253
- */
254
- function afterSolverMoveOnBoard(move: Move): void {
255
- if (pendingWrongMoveReveal !== null) {
256
- scheduleWrongMoveReveal(move);
257
- return;
115
+
116
+ let solutionPreviewTree = $state<ChessTree | null>(null);
117
+
118
+ /**
119
+ * Представляет синхронизацию доступности перетаскивания с очередью хода и состоянием «решено».
120
+ */
121
+ function syncBoardDragFromChessTurn(): void {
122
+ if (pendingWrongMoveReveal !== null) {
123
+ chessboard.draggable = Draggable.NONE;
124
+ return;
125
+ }
126
+
127
+ const solver = puzzleSolverColor();
128
+ chessboard.draggable = solved
129
+ ? previewChess.turn() === Color.WHITE
130
+ ? Draggable.WHITE
131
+ : Draggable.BLACK
132
+ : previewChess.turn() === solver
133
+ ? solver === Color.WHITE
134
+ ? Draggable.WHITE
135
+ : Draggable.BLACK
136
+ : Draggable.NONE;
258
137
  }
259
138
 
260
- syncCorrectSolverMoveNagBadge(move);
261
- syncBoardDragFromChessTurn();
262
- if (!solved) {
263
- scheduleOpponentAutoResponse();
139
+ /**
140
+ * Представляет отмену отложенного отката ошибочного хода без записи варианта в дерево.
141
+ */
142
+ function dismissPendingWrongMoveReveal(): void {
143
+ if (wrongRevealTimer !== null) {
144
+ clearTimeout(wrongRevealTimer);
145
+ wrongRevealTimer = null;
146
+ }
147
+
148
+ const pending = pendingWrongMoveReveal;
149
+ if (pending === null) return;
150
+
151
+ pendingWrongMoveReveal = null;
152
+ previewChess.setFen(pending.revertFen);
153
+ chessboard.fen = pending.revertFen;
154
+ chessboard.addLastMove(pending.revertLastMove);
155
+ syncMoveEvaluationNagBadge(chessboard, {
156
+ nags: undefined,
157
+ lastMove: pending.revertLastMove,
158
+ });
159
+ syncBoardDragFromChessTurn();
264
160
  }
265
- }
266
161
 
267
- /**
268
- * Представляет добавление пути в дерево ученика без очистки уже пройденных линий.
269
- */
270
- function ensureStudentPreviewTreeHasPath(path: number[]): void {
271
- const solution = solutionPreviewTree;
272
- if (!solution) return;
162
+ /**
163
+ * Представляет досрочное завершение показа ошибочного хода перед следующим ходом решателя.
164
+ */
165
+ function cancelPendingWrongMoveReveal(): void {
166
+ if (pendingWrongMoveReveal === null) return;
273
167
 
274
- studentPreviewTree.currentNode = studentPreviewTree.rootNode.moves;
168
+ if (wrongRevealTimer !== null) {
169
+ clearTimeout(wrongRevealTimer);
170
+ wrongRevealTimer = null;
171
+ }
275
172
 
276
- let src = solution.rootNode.moves;
277
- const solver = puzzleSolverColor();
173
+ finishWrongMoveReveal();
174
+ }
278
175
 
279
- applyPlayedSolverNagsAlongPath(solution, path, solver);
176
+ /**
177
+ * Представляет показ NAG ✗ на доске и отложенный откат ошибочного хода.
178
+ */
179
+ function scheduleWrongMoveReveal(wrongMove: Move): void {
180
+ const pending = pendingWrongMoveReveal;
181
+ if (pending === null) return;
280
182
 
281
- for (const idx of path) {
282
- const next = src.children[idx];
283
- if (!next) break;
183
+ syncMoveEvaluationNagBadge(chessboard, {
184
+ nags: [PUZZLE_BRANCH_WRONG_NAG_ID],
185
+ lastMove: wrongMove,
186
+ });
187
+ syncBoardDragFromChessTurn();
284
188
 
285
- const studentNags = next.data.nags ? [...next.data.nags] : undefined;
189
+ wrongRevealTimer = setTimeout(() => {
190
+ wrongRevealTimer = null;
191
+ finishWrongMoveReveal();
192
+ }, PREVIEW_WRONG_MOVE_REVEAL_MS);
193
+ }
286
194
 
287
- const existingChild = studentPreviewTree.currentNode.children.find(
288
- (c) => c.data.san === next.data.san,
289
- );
290
- if (existingChild) {
291
- existingChild.data.nags = studentNags;
292
- }
195
+ /**
196
+ * Представляет завершение показа ошибочного хода: запись варианта в дерево и откат позиции на доске.
197
+ */
198
+ function finishWrongMoveReveal(): void {
199
+ const pending = pendingWrongMoveReveal;
200
+ pendingWrongMoveReveal = null;
201
+ if (pending === null) return;
293
202
 
294
- studentPreviewTree.addNodeToCurrent({
295
- id: "",
296
- children: [],
297
- data: {
298
- ...next.data,
299
- nags: studentNags,
300
- },
301
- });
302
- src = next;
203
+ previewChess.setFen(pending.revertFen);
204
+ handleWrongOutcome(pending.outcome, pending.playedSan);
205
+
206
+ chessboard.fen = pending.revertFen;
207
+ chessboard.addLastMove(pending.revertLastMove);
208
+ syncMoveEvaluationNagBadge(chessboard, {
209
+ nags: undefined,
210
+ lastMove: pending.revertLastMove,
211
+ });
212
+ syncBoardDragFromChessTurn();
213
+ }
214
+
215
+ /**
216
+ * Представляет синхронизацию NAG ✓ на доске после верного хода решателя.
217
+ */
218
+ function syncCorrectSolverMoveNagBadge(move: Move): void {
219
+ syncMoveEvaluationNagBadge(chessboard, {
220
+ nags: [PUZZLE_BRANCH_CORRECT_NAG_ID],
221
+ lastMove: move,
222
+ });
223
+ }
224
+
225
+ /**
226
+ * Представляет данные узла для бейджа NAG на доске в режиме свободного анализа (без ✓/✗ задачи).
227
+ */
228
+ function nodeDataForAnalysisBoardBadge(data: {
229
+ nags?: number[];
230
+ lastMove?: Move | null;
231
+ }): { nags?: number[]; lastMove?: Move | null } {
232
+ const nags = data.nags?.filter((n) => !isPuzzleBranchNag(n));
233
+ return {
234
+ nags: nags && nags.length > 0 ? nags : undefined,
235
+ lastMove: data.lastMove ?? null,
236
+ };
303
237
  }
304
238
 
305
- const solverColor = puzzleSolverColor();
306
- normalizePuzzleTreeMainLine(studentPreviewTree.rootNode.moves, solverColor);
307
- }
239
+ /**
240
+ * Представляет синхронизацию бейджей NAG и маркеров доски с текущим узлом дерева решения (свободный анализ).
241
+ */
242
+ function syncAnalysisBoardFromCurrentNode(): void {
243
+ const tree = solutionPreviewTree;
244
+ if (!tree) return;
245
+
246
+ syncMoveEvaluationNagBadge(
247
+ chessboard,
248
+ nodeDataForAnalysisBoardBadge(tree.currentNode.data),
249
+ );
250
+ chessboard.setNodeMarkers(
251
+ tree.currentNode.data.parsedFirstComment?.shapes,
252
+ );
253
+ syncBoardDragFromChessTurn();
254
+ }
308
255
 
309
- /**
310
- * Представляет нормализацию главной линии и NAG только в дереве решения (PGN).
311
- */
312
- function normalizeSolutionTree(): void {
313
- const solution = solutionPreviewTree;
314
- if (!solution) return;
256
+ /**
257
+ * Представляет обработку UI после применения хода решателя на доске.
258
+ */
259
+ function afterSolverMoveOnBoard(move: Move): void {
260
+ if (pendingWrongMoveReveal !== null) {
261
+ scheduleWrongMoveReveal(move);
262
+ return;
263
+ }
315
264
 
316
- const solverColor = puzzleSolverColor();
317
- if (normalizePuzzleTreeMainLine(solution.rootNode.moves, solverColor)) {
318
- solution.mutationVersion++;
265
+ syncCorrectSolverMoveNagBadge(move);
266
+ syncBoardDragFromChessTurn();
267
+ if (!solved) {
268
+ scheduleOpponentAutoResponse();
269
+ }
319
270
  }
320
- cursorPath = puzzlePreviewRemapPathAfterReorder(
321
- solution.rootNode.moves,
322
- cursorPath,
323
- );
324
- }
325
-
326
- /**
327
- * Представляет синхронизацию дерева ученика с пройденным путём решения (после верного хода).
328
- */
329
- function syncStudentTreeFromSolutionPath(path: number[]): void {
330
- ensureStudentPreviewTreeHasPath(path);
331
- studentPreviewTree.forcedNodeId = null;
332
- studentPreviewTree.mutationVersion++;
333
- }
334
-
335
- /**
336
- * Представляет добавление отсутствующего в PGN хода в дерево решения и дерево ученика с NAG ✗.
337
- */
338
- function addAdHocWrongMoveToTrees(playedSan: string): boolean {
339
- const solution = solutionPreviewTree;
340
- if (!solution) return false;
341
-
342
- const addedNode = puzzlePreviewAddAdHocWrongMoveToSolutionTree(
343
- solution,
344
- cursorPath,
345
- previewChess.fen(),
346
- playedSan,
347
- );
348
- if (!addedNode) return false;
349
271
 
350
- normalizeSolutionTree();
272
+ /**
273
+ * Представляет добавление пути в дерево ученика без очистки уже пройденных линий.
274
+ */
275
+ function ensureStudentPreviewTreeHasPath(path: number[]): void {
276
+ const solution = solutionPreviewTree;
277
+ if (!solution) return;
351
278
 
352
- ensureStudentPreviewTreeHasPath(cursorPath);
353
- const studentFork = studentPreviewTree.currentNode;
354
- studentPreviewTree.addNodeToCurrent({
355
- id: "",
356
- children: [],
357
- data: { ...addedNode.data },
358
- });
359
- studentPreviewTree.forcedNodeId = studentFork.id;
360
- studentPreviewTree.currentNode = studentFork;
361
-
362
- const solverColor = puzzleSolverColor();
363
- normalizePuzzleTreeMainLine(studentPreviewTree.rootNode.moves, solverColor);
364
-
365
- studentPreviewTree.mutationVersion++;
366
-
367
- return true;
368
- }
369
-
370
- /**
371
- * Представляет обработку ошибочного хода в превью.
372
- */
373
- function handleWrongOutcome(
374
- outcome: TSolverMoveOutcome,
375
- playedSan: string,
376
- ): void {
377
- const solution = solutionPreviewTree;
378
- if (!solution) return;
379
-
380
- if (wrongRevealTimer !== null) {
381
- clearTimeout(wrongRevealTimer);
382
- wrongRevealTimer = null;
279
+ studentPreviewTree.currentNode = studentPreviewTree.rootNode.moves;
280
+
281
+ let src = solution.rootNode.moves;
282
+ const solver = puzzleSolverColor();
283
+
284
+ applyPlayedSolverNagsAlongPath(solution, path, solver);
285
+
286
+ for (const idx of path) {
287
+ const next = src.children[idx];
288
+ if (!next) break;
289
+
290
+ const studentNags = next.data.nags
291
+ ? [...next.data.nags]
292
+ : undefined;
293
+
294
+ const existingChild = studentPreviewTree.currentNode.children.find(
295
+ (c) => c.data.san === next.data.san,
296
+ );
297
+ if (existingChild) {
298
+ existingChild.data.nags = studentNags;
299
+ }
300
+
301
+ studentPreviewTree.addNodeToCurrent({
302
+ id: "",
303
+ children: [],
304
+ data: {
305
+ ...next.data,
306
+ nags: studentNags,
307
+ },
308
+ });
309
+ src = next;
310
+ }
311
+
312
+ const solverColor = puzzleSolverColor();
313
+ normalizePuzzleTreeMainLine(
314
+ studentPreviewTree.rootNode.moves,
315
+ solverColor,
316
+ );
383
317
  }
384
- if (advanceLineTimer !== null) {
385
- clearTimeout(advanceLineTimer);
386
- advanceLineTimer = null;
318
+
319
+ /**
320
+ * Представляет нормализацию главной линии и NAG только в дереве решения (PGN).
321
+ */
322
+ function normalizeSolutionTree(): void {
323
+ const solution = solutionPreviewTree;
324
+ if (!solution) return;
325
+
326
+ const solverColor = puzzleSolverColor();
327
+ if (normalizePuzzleTreeMainLine(solution.rootNode.moves, solverColor)) {
328
+ solution.mutationVersion++;
329
+ }
330
+ cursorPath = puzzlePreviewRemapPathAfterReorder(
331
+ solution.rootNode.moves,
332
+ cursorPath,
333
+ );
387
334
  }
388
- if (opponentAutoTimer !== null) {
389
- clearTimeout(opponentAutoTimer);
390
- opponentAutoTimer = null;
335
+
336
+ /**
337
+ * Представляет синхронизацию дерева ученика с пройденным путём решения (после верного хода).
338
+ */
339
+ function syncStudentTreeFromSolutionPath(path: number[]): void {
340
+ ensureStudentPreviewTreeHasPath(path);
341
+ studentPreviewTree.forcedNodeId = null;
342
+ studentPreviewTree.mutationVersion++;
391
343
  }
392
344
 
393
- if (outcome.kind === "wrong_marked_variant") {
394
- wrongFeedback =
395
- "Неверно. Открываю введённый вариант в дереве, ход отменён.";
396
- onOutcome?.("failed");
397
- ensureStudentPreviewTreeHasPath(cursorPath);
398
- const studentFork = studentPreviewTree.currentNode;
399
- puzzlePreviewDupSubtreeUnderStudentCursor(
400
- studentPreviewTree,
401
- outcome.wrongBranchRoot,
402
- );
403
- studentPreviewTree.forcedNodeId = studentFork.id;
404
- studentPreviewTree.currentNode = studentFork;
405
-
406
- const solverColor = puzzleSolverColor();
407
- normalizePuzzleTreeMainLine(studentPreviewTree.rootNode.moves, solverColor);
408
-
409
- studentPreviewTree.mutationVersion++;
410
- normalizeSolutionTree();
411
- return;
345
+ /**
346
+ * Представляет добавление отсутствующего в PGN хода в дерево решения и дерево ученика с NAG ✗.
347
+ */
348
+ function addAdHocWrongMoveToTrees(playedSan: string): boolean {
349
+ const solution = solutionPreviewTree;
350
+ if (!solution) return false;
351
+
352
+ const addedNode = puzzlePreviewAddAdHocWrongMoveToSolutionTree(
353
+ solution,
354
+ cursorPath,
355
+ previewChess.fen(),
356
+ playedSan,
357
+ );
358
+ if (!addedNode) return false;
359
+
360
+ normalizeSolutionTree();
361
+
362
+ ensureStudentPreviewTreeHasPath(cursorPath);
363
+ const studentFork = studentPreviewTree.currentNode;
364
+ studentPreviewTree.addNodeToCurrent({
365
+ id: "",
366
+ children: [],
367
+ data: { ...addedNode.data },
368
+ });
369
+ studentPreviewTree.forcedNodeId = studentFork.id;
370
+ studentPreviewTree.currentNode = studentFork;
371
+
372
+ const solverColor = puzzleSolverColor();
373
+ normalizePuzzleTreeMainLine(
374
+ studentPreviewTree.rootNode.moves,
375
+ solverColor,
376
+ );
377
+
378
+ studentPreviewTree.mutationVersion++;
379
+
380
+ return true;
412
381
  }
413
382
 
414
- if (outcome.kind === "no_matching_variant") {
415
- if (addAdHocWrongMoveToTrees(playedSan)) {
416
- wrongFeedback =
417
- "Неверно. Ход добавлен в дерево вариантов, позиция на доске отменена.";
418
- } else {
383
+ /**
384
+ * Представляет обработку ошибочного хода в превью.
385
+ */
386
+ function handleWrongOutcome(
387
+ outcome: TSolverMoveOutcome,
388
+ playedSan: string,
389
+ ): void {
390
+ const solution = solutionPreviewTree;
391
+ if (!solution) return;
392
+
393
+ if (wrongRevealTimer !== null) {
394
+ clearTimeout(wrongRevealTimer);
395
+ wrongRevealTimer = null;
396
+ }
397
+ if (advanceLineTimer !== null) {
398
+ clearTimeout(advanceLineTimer);
399
+ advanceLineTimer = null;
400
+ }
401
+ if (opponentAutoTimer !== null) {
402
+ clearTimeout(opponentAutoTimer);
403
+ opponentAutoTimer = null;
404
+ }
405
+
406
+ if (outcome.kind === "wrong_marked_variant") {
407
+ wrongFeedback =
408
+ "Неверно. Открываю введённый вариант в дереве, ход отменён.";
409
+ onOutcome?.("failed");
410
+ ensureStudentPreviewTreeHasPath(cursorPath);
411
+ const studentFork = studentPreviewTree.currentNode;
412
+ puzzlePreviewDupSubtreeUnderStudentCursor(
413
+ studentPreviewTree,
414
+ outcome.wrongBranchRoot,
415
+ );
416
+ studentPreviewTree.forcedNodeId = studentFork.id;
417
+ studentPreviewTree.currentNode = studentFork;
418
+
419
+ const solverColor = puzzleSolverColor();
420
+ normalizePuzzleTreeMainLine(
421
+ studentPreviewTree.rootNode.moves,
422
+ solverColor,
423
+ );
424
+
425
+ studentPreviewTree.mutationVersion++;
426
+ normalizeSolutionTree();
427
+ return;
428
+ }
429
+
430
+ if (outcome.kind === "no_matching_variant") {
431
+ if (addAdHocWrongMoveToTrees(playedSan)) {
432
+ wrongFeedback =
433
+ "Неверно. Ход добавлен в дерево вариантов, позиция на доске отменена.";
434
+ } else {
435
+ wrongFeedback = PREVIEW_WRONG_NO_VARIANT_MESSAGE;
436
+ }
437
+ onOutcome?.("failed");
438
+ return;
439
+ }
440
+
419
441
  wrongFeedback = PREVIEW_WRONG_NO_VARIANT_MESSAGE;
420
- }
421
- onOutcome?.("failed");
422
- return;
442
+ onOutcome?.("failed");
423
443
  }
424
444
 
425
- wrongFeedback = PREVIEW_WRONG_NO_VARIANT_MESSAGE;
426
- onOutcome?.("failed");
427
- }
428
-
429
- /**
430
- * Представляет установку позиции и дерева ученика по пути из корня.
431
- */
432
- function setStateFromPath(path: number[]): void {
433
- dismissPendingWrongMoveReveal();
434
-
435
- const solution = solutionPreviewTree;
436
- if (!solution) return;
437
-
438
- previewChess.setFen(layoutInitialFen);
439
- let node = solution.rootNode.moves;
440
- for (const idx of path) {
441
- const child = node.children[idx];
442
- if (!child) break;
443
- previewChess.makeSanMove(child.data.san);
444
- node = child;
445
+ /**
446
+ * Представляет установку позиции и дерева ученика по пути из корня.
447
+ */
448
+ function setStateFromPath(path: number[]): void {
449
+ dismissPendingWrongMoveReveal();
450
+
451
+ const solution = solutionPreviewTree;
452
+ if (!solution) return;
453
+
454
+ previewChess.setFen(layoutInitialFen);
455
+ let node = solution.rootNode.moves;
456
+ for (const idx of path) {
457
+ const child = node.children[idx];
458
+ if (!child) break;
459
+ previewChess.makeSanMove(child.data.san);
460
+ node = child;
461
+ }
462
+
463
+ cursorPath = path;
464
+ syncStudentTreeFromSolutionPath(path);
465
+ chessboard.fen = previewChess.fen();
466
+ const leaf = puzzlePreviewNodeAtPath(solution.rootNode.moves, path);
467
+ chessboard.addLastMove(leaf?.data.lastMove ?? null);
468
+ syncMoveEvaluationNagBadge(chessboard, {
469
+ nags: undefined,
470
+ lastMove: leaf?.data.lastMove ?? null,
471
+ });
472
+ syncBoardDragFromChessTurn();
445
473
  }
446
474
 
447
- cursorPath = path;
448
- syncStudentTreeFromSolutionPath(path);
449
- chessboard.fen = previewChess.fen();
450
- const leaf = puzzlePreviewNodeAtPath(solution.rootNode.moves, path);
451
- chessboard.addLastMove(leaf?.data.lastMove ?? null);
452
- syncMoveEvaluationNagBadge(chessboard, {
453
- nags: undefined,
454
- lastMove: leaf?.data.lastMove ?? null,
455
- });
456
- syncBoardDragFromChessTurn();
457
- }
458
-
459
- /**
460
- * Представляет ответ соперника по главному варианту после хода решателя (без применения к движку).
461
- */
462
- function getOpponentMainLineReply(
463
- pathAfterSolver: number[],
464
- ): { san: string; path: number[] } | null {
465
- const solution = solutionPreviewTree;
466
- if (!solution) return null;
467
-
468
- if (puzzlePreviewSideToMoveFromFen(previewChess.fen()) === puzzleSolverColor()) {
469
- return null;
475
+ /**
476
+ * Представляет ответ соперника по главному варианту после хода решателя (без применения к движку).
477
+ */
478
+ function getOpponentMainLineReply(
479
+ pathAfterSolver: number[],
480
+ ): { san: string; path: number[] } | null {
481
+ const solution = solutionPreviewTree;
482
+ if (!solution) return null;
483
+
484
+ if (
485
+ puzzlePreviewSideToMoveFromFen(previewChess.fen()) ===
486
+ puzzleSolverColor()
487
+ ) {
488
+ return null;
489
+ }
490
+
491
+ const node = puzzlePreviewNodeAtPath(
492
+ solution.rootNode.moves,
493
+ pathAfterSolver,
494
+ );
495
+ if (!node || node.children.length === 0) return null;
496
+
497
+ return {
498
+ san: node.children[0]!.data.san,
499
+ path: [...pathAfterSolver, 0],
500
+ };
470
501
  }
471
502
 
472
- const node = puzzlePreviewNodeAtPath(
473
- solution.rootNode.moves,
474
- pathAfterSolver,
475
- );
476
- if (!node || node.children.length === 0) return null;
503
+ /**
504
+ * Представляет проверку конца линии и переход к следующему варианту ответа соперника.
505
+ */
506
+ function checkSolvedAndMaybeAdvance(): void {
507
+ const solution = solutionPreviewTree;
508
+ if (!solution) return;
477
509
 
478
- return {
479
- san: node.children[0]!.data.san,
480
- path: [...pathAfterSolver, 0],
481
- };
482
- }
483
-
484
- /**
485
- * Представляет проверку конца линии и переход к следующему варианту ответа соперника.
486
- */
487
- function checkSolvedAndMaybeAdvance(): void {
488
- const solution = solutionPreviewTree;
489
- if (!solution) return;
490
-
491
- const leaf = puzzlePreviewNodeAtPath(solution.rootNode.moves, cursorPath);
492
- if (
493
- leaf &&
494
- puzzlePreviewIsSolvedPosition(previewChess, leaf, puzzleSolverColor())
495
- ) {
496
- if (advanceLineTimer !== null) {
497
- clearTimeout(advanceLineTimer);
498
- advanceLineTimer = null;
499
- }
500
- advanceLineTimer = setTimeout(() => {
501
- const advanced = maybeAdvanceToNextOpponentLine();
502
- if (!advanced) {
503
- const target = puzzlePreviewNodeAtPath(
510
+ const leaf = puzzlePreviewNodeAtPath(
504
511
  solution.rootNode.moves,
505
512
  cursorPath,
506
- );
507
- solution.currentNode = target ?? solution.rootNode.moves;
508
- solved = true;
509
- onOutcome?.("solved");
510
- syncBoardFromSolutionTreeNode();
513
+ );
514
+ if (
515
+ leaf &&
516
+ puzzlePreviewIsSolvedPosition(
517
+ previewChess,
518
+ leaf,
519
+ puzzleSolverColor(),
520
+ )
521
+ ) {
522
+ if (advanceLineTimer !== null) {
523
+ clearTimeout(advanceLineTimer);
524
+ advanceLineTimer = null;
525
+ }
526
+ advanceLineTimer = setTimeout(() => {
527
+ const advanced = maybeAdvanceToNextOpponentLine();
528
+ if (!advanced) {
529
+ const target = puzzlePreviewNodeAtPath(
530
+ solution.rootNode.moves,
531
+ cursorPath,
532
+ );
533
+ solution.currentNode = target ?? solution.rootNode.moves;
534
+ solved = true;
535
+ onOutcome?.("solved");
536
+ syncBoardFromSolutionTreeNode();
537
+ }
538
+ syncBoardDragFromChessTurn();
539
+ advanceLineTimer = null;
540
+ }, 250);
511
541
  }
512
- syncBoardDragFromChessTurn();
513
- advanceLineTimer = null;
514
- }, 250);
515
- }
516
- }
517
-
518
- /**
519
- * Представляет анимированный автоматический ход соперника после хода решателя.
520
- */
521
- function playOpponentAutoResponse(pathAfterSolver: number[]): void {
522
- const reply = getOpponentMainLineReply(pathAfterSolver);
523
- if (!reply) {
524
- checkSolvedAndMaybeAdvance();
525
- return;
526
542
  }
527
543
 
528
- let opponentMove: Move;
529
- try {
530
- opponentMove = previewChess.makeSanMove(reply.san);
531
- } catch {
532
- return;
533
- }
544
+ /**
545
+ * Представляет анимированный автоматический ход соперника после хода решателя.
546
+ */
547
+ function playOpponentAutoResponse(pathAfterSolver: number[]): void {
548
+ const reply = getOpponentMainLineReply(pathAfterSolver);
549
+ if (!reply) {
550
+ checkSolvedAndMaybeAdvance();
551
+ return;
552
+ }
534
553
 
535
- cursorPath = reply.path;
536
- syncStudentTreeFromSolutionPath(cursorPath);
554
+ let opponentMove: Move;
555
+ try {
556
+ opponentMove = previewChess.makeSanMove(reply.san);
557
+ } catch {
558
+ return;
559
+ }
537
560
 
538
- chessboard.animationTime = OPPONENT_MOVE_ANIMATION_MS;
539
- chessboard.fen = previewChess.fen();
540
- chessboard.addLastMove(opponentMove);
541
- syncMoveEvaluationNagBadge(chessboard, {
542
- nags: undefined,
543
- lastMove: opponentMove,
544
- });
545
- syncBoardDragFromChessTurn();
546
-
547
- checkSolvedAndMaybeAdvance();
548
- }
549
-
550
- /**
551
- * Представляет отложенный автоматический ответ соперника после анимации хода решателя.
552
- */
553
- function scheduleOpponentAutoResponse(): void {
554
- if (opponentAutoTimer !== null) {
555
- clearTimeout(opponentAutoTimer);
556
- opponentAutoTimer = null;
561
+ cursorPath = reply.path;
562
+ syncStudentTreeFromSolutionPath(cursorPath);
563
+
564
+ chessboard.animationTime = OPPONENT_MOVE_ANIMATION_MS;
565
+ chessboard.fen = previewChess.fen();
566
+ chessboard.addLastMove(opponentMove);
567
+ syncMoveEvaluationNagBadge(chessboard, {
568
+ nags: undefined,
569
+ lastMove: opponentMove,
570
+ });
571
+ syncBoardDragFromChessTurn();
572
+
573
+ checkSolvedAndMaybeAdvance();
557
574
  }
558
575
 
559
- const pathAfterSolver = [...cursorPath];
560
- const delayMs = chessboard.animationTime || OPPONENT_MOVE_ANIMATION_MS;
561
- opponentAutoTimer = setTimeout(() => {
562
- playOpponentAutoResponse(pathAfterSolver);
563
- opponentAutoTimer = null;
564
- }, delayMs);
565
- }
566
-
567
- /**
568
- * Представляет поиск следующей линии по развилкам соперника (DFS: чем глубже развилка — тем раньше возврат).
569
- */
570
- function findNextOpponentLine(path: number[]): number[] | null {
571
- const solution = solutionPreviewTree;
572
- if (!solution) return null;
573
-
574
- const solver = puzzleSolverColor();
575
-
576
- let node = solution.rootNode.moves;
577
- for (let depth = 0; depth <= path.length; depth++) {
578
- const sideToMove = puzzlePreviewSideToMoveFromFen(node.data.fen);
579
- const isOpponentToMove = sideToMove !== solver;
580
- const isFork = node.children.length > 1;
581
-
582
- if (isOpponentToMove && isFork && depth < path.length) {
583
- const chosenIdx = path[depth]!;
584
- if (chosenIdx < node.children.length - 1) {
585
- /* кандидат на «следующую линию» с этого форка — ниже второй проход */
576
+ /**
577
+ * Представляет отложенный автоматический ответ соперника после анимации хода решателя.
578
+ */
579
+ function scheduleOpponentAutoResponse(): void {
580
+ if (opponentAutoTimer !== null) {
581
+ clearTimeout(opponentAutoTimer);
582
+ opponentAutoTimer = null;
586
583
  }
587
- }
588
584
 
589
- if (depth === path.length) break;
590
- const child = node.children[path[depth]!];
591
- if (!child) break;
592
- node = child;
585
+ const pathAfterSolver = [...cursorPath];
586
+ const delayMs = chessboard.animationTime || OPPONENT_MOVE_ANIMATION_MS;
587
+ opponentAutoTimer = setTimeout(() => {
588
+ playOpponentAutoResponse(pathAfterSolver);
589
+ opponentAutoTimer = null;
590
+ }, delayMs);
593
591
  }
594
592
 
595
- node = solution.rootNode.moves;
596
- let bestForkDepth: number | null = null;
597
- let bestNextIdx = 0;
598
- for (let depth = 0; depth < path.length; depth++) {
599
- const sideToMove = puzzlePreviewSideToMoveFromFen(node.data.fen);
600
- const isOpponentToMove = sideToMove !== solver;
601
- if (isOpponentToMove && node.children.length > 1) {
602
- const chosenIdx = path[depth]!;
603
- if (chosenIdx < node.children.length - 1) {
604
- bestForkDepth = depth;
605
- bestNextIdx = chosenIdx + 1;
593
+ /**
594
+ * Представляет поиск следующей линии по развилкам соперника (DFS: чем глубже развилка тем раньше возврат).
595
+ */
596
+ function findNextOpponentLine(path: number[]): number[] | null {
597
+ const solution = solutionPreviewTree;
598
+ if (!solution) return null;
599
+
600
+ const solver = puzzleSolverColor();
601
+
602
+ let node = solution.rootNode.moves;
603
+ for (let depth = 0; depth <= path.length; depth++) {
604
+ const sideToMove = puzzlePreviewSideToMoveFromFen(node.data.fen);
605
+ const isOpponentToMove = sideToMove !== solver;
606
+ const isFork = node.children.length > 1;
607
+
608
+ if (isOpponentToMove && isFork && depth < path.length) {
609
+ const chosenIdx = path[depth]!;
610
+ if (chosenIdx < node.children.length - 1) {
611
+ /* кандидат на «следующую линию» с этого форка — ниже второй проход */
612
+ }
613
+ }
614
+
615
+ if (depth === path.length) break;
616
+ const child = node.children[path[depth]!];
617
+ if (!child) break;
618
+ node = child;
606
619
  }
607
- }
608
- const child = node.children[path[depth]!];
609
- if (!child) break;
610
- node = child;
620
+
621
+ node = solution.rootNode.moves;
622
+ let bestForkDepth: number | null = null;
623
+ let bestNextIdx = 0;
624
+ for (let depth = 0; depth < path.length; depth++) {
625
+ const sideToMove = puzzlePreviewSideToMoveFromFen(node.data.fen);
626
+ const isOpponentToMove = sideToMove !== solver;
627
+ if (isOpponentToMove && node.children.length > 1) {
628
+ const chosenIdx = path[depth]!;
629
+ if (chosenIdx < node.children.length - 1) {
630
+ bestForkDepth = depth;
631
+ bestNextIdx = chosenIdx + 1;
632
+ }
633
+ }
634
+ const child = node.children[path[depth]!];
635
+ if (!child) break;
636
+ node = child;
637
+ }
638
+
639
+ if (bestForkDepth === null) return null;
640
+ return [...path.slice(0, bestForkDepth), bestNextIdx];
611
641
  }
612
642
 
613
- if (bestForkDepth === null) return null;
614
- return [...path.slice(0, bestForkDepth), bestNextIdx];
615
- }
643
+ /**
644
+ * Представляет переход на следующую линию ответов соперника, если возможно.
645
+ */
646
+ function maybeAdvanceToNextOpponentLine(): boolean {
647
+ const nextBase = findNextOpponentLine(cursorPath);
648
+ if (!nextBase) return false;
616
649
 
617
- /**
618
- * Представляет переход на следующую линию ответов соперника, если возможно.
619
- */
620
- function maybeAdvanceToNextOpponentLine(): boolean {
621
- const nextBase = findNextOpponentLine(cursorPath);
622
- if (!nextBase) return false;
650
+ solved = false;
651
+ wrongFeedback = null;
623
652
 
624
- solved = false;
625
- wrongFeedback = null;
653
+ setStateFromPath(nextBase);
654
+ return true;
655
+ }
626
656
 
627
- setStateFromPath(nextBase);
628
- return true;
629
- }
657
+ /**
658
+ * Представляет попытку применить SAN хода решателя с автоответами соперника.
659
+ */
660
+ function tryApplySolverSan(san: string): Move | undefined {
661
+ const solution = solutionPreviewTree;
662
+ if (!solution || solved) return undefined;
630
663
 
631
- /**
632
- * Представляет попытку применить SAN хода решателя с автоответами соперника.
633
- */
634
- function tryApplySolverSan(san: string): Move | undefined {
635
- const solution = solutionPreviewTree;
636
- if (!solution || solved) return undefined;
664
+ cancelPendingWrongMoveReveal();
637
665
 
638
- cancelPendingWrongMoveReveal();
666
+ const cursorNode = puzzlePreviewNodeAtPath(
667
+ solution.rootNode.moves,
668
+ cursorPath,
669
+ );
670
+ if (!cursorNode) return undefined;
671
+
672
+ const outcome = puzzlePreviewClassifySolverMove(
673
+ cursorNode,
674
+ previewChess.fen(),
675
+ san,
676
+ puzzleSolverColor(),
677
+ );
678
+
679
+ if (outcome.kind !== "correct") {
680
+ const revertFen = previewChess.fen();
681
+ const revertLastMove = cursorNode.data.lastMove ?? null;
682
+ try {
683
+ const wrongMove = previewChess.makeSanMove(san);
684
+ pendingWrongMoveReveal = {
685
+ outcome,
686
+ playedSan: san,
687
+ revertFen,
688
+ revertLastMove,
689
+ };
690
+ lastSolverMoveForBoard = wrongMove;
691
+ return wrongMove;
692
+ } catch {
693
+ pendingWrongMoveReveal = null;
694
+ wrongFeedback = "Ход недопустим в этой позиции.";
695
+ onOutcome?.("failed");
696
+ return undefined;
697
+ }
698
+ }
639
699
 
640
- const cursorNode = puzzlePreviewNodeAtPath(
641
- solution.rootNode.moves,
642
- cursorPath,
643
- );
644
- if (!cursorNode) return undefined;
700
+ try {
701
+ const pathAfterSolver = [...cursorPath, outcome.childIndex];
702
+ const moveRecord = previewChess.makeSanMove(san);
703
+ cursorPath = pathAfterSolver;
704
+ syncStudentTreeFromSolutionPath(pathAfterSolver);
645
705
 
646
- const outcome = puzzlePreviewClassifySolverMove(
647
- cursorNode,
648
- previewChess.fen(),
649
- san,
650
- puzzleSolverColor(),
651
- );
706
+ wrongFeedback = null;
707
+ lastSolverMoveForBoard = moveRecord;
708
+ syncBoardDragFromChessTurn();
652
709
 
653
- if (outcome.kind !== "correct") {
654
- const revertFen = previewChess.fen();
655
- const revertLastMove = cursorNode.data.lastMove ?? null;
656
- try {
657
- const wrongMove = previewChess.makeSanMove(san);
658
- pendingWrongMoveReveal = {
659
- outcome,
660
- playedSan: san,
661
- revertFen,
662
- revertLastMove,
663
- };
664
- lastSolverMoveForBoard = wrongMove;
665
- return wrongMove;
666
- } catch {
667
- pendingWrongMoveReveal = null;
668
- wrongFeedback = "Ход недопустим в этой позиции.";
669
- onOutcome?.("failed");
670
- return undefined;
671
- }
710
+ return moveRecord;
711
+ } catch {
712
+ wrongFeedback = "Ход недопустим в этой позиции.";
713
+ onOutcome?.("failed");
714
+ return undefined;
715
+ }
672
716
  }
673
717
 
674
- try {
675
- const pathAfterSolver = [...cursorPath, outcome.childIndex];
676
- const moveRecord = previewChess.makeSanMove(san);
677
- cursorPath = pathAfterSolver;
678
- syncStudentTreeFromSolutionPath(pathAfterSolver);
679
-
680
- wrongFeedback = null;
681
- lastSolverMoveForBoard = moveRecord;
682
- syncBoardDragFromChessTurn();
683
-
684
- return moveRecord;
685
- } catch {
686
- wrongFeedback = "Ход недопустим в этой позиции.";
687
- onOutcome?.("failed");
688
- return undefined;
689
- }
690
- }
691
-
692
- const actions: IChessBoardActions = {
693
- game: {
694
- possibleMovesOnSquare: (square: Square) => previewChess.moves(square),
695
- beforePieceMoveSan(san: string) {
696
- if (solved) {
697
- try {
698
- const move = previewChess.makeSanMove(san);
699
- const fen = previewChess.fen();
700
- const { halfMoves, fullMoves } = calculatePly(fen);
701
- solutionPreviewTree?.addNodeToCurrent({
702
- id: "",
703
- children: [],
704
- data: {
705
- fen,
706
- san,
707
- ply: halfMoves,
708
- fullMoves,
709
- lastMove: move,
710
- },
718
+ const actions: IChessBoardActions = {
719
+ game: {
720
+ possibleMovesOnSquare: (square: Square) =>
721
+ previewChess.moves(square),
722
+ beforePieceMoveSan(san: string) {
723
+ if (solved) {
724
+ try {
725
+ const move = previewChess.makeSanMove(san);
726
+ const fen = previewChess.fen();
727
+ const { halfMoves, fullMoves } = calculatePly(fen);
728
+ solutionPreviewTree?.addNodeToCurrent({
729
+ id: "",
730
+ children: [],
731
+ data: {
732
+ fen,
733
+ san,
734
+ ply: halfMoves,
735
+ fullMoves,
736
+ lastMove: move,
737
+ },
738
+ });
739
+ return {
740
+ move,
741
+ fen,
742
+ turn: previewChess.turn(),
743
+ };
744
+ } catch {
745
+ return undefined;
746
+ }
747
+ }
748
+ const move = tryApplySolverSan(san);
749
+ if (!move) return undefined;
750
+ return {
751
+ move,
752
+ fen: previewChess.fen(),
753
+ turn: previewChess.turn(),
754
+ };
755
+ },
756
+ afterPieceMoveSan: (move) => {
757
+ lastSolverMoveForBoard = null;
758
+ if (solved) {
759
+ syncAnalysisBoardFromCurrentNode();
760
+ return;
761
+ }
762
+ afterSolverMoveOnBoard(move);
763
+ },
764
+ beforePieceMove: (from, to, promotion) => {
765
+ if (solved) {
766
+ try {
767
+ const san = previewChess.getSanForMove({
768
+ from,
769
+ to,
770
+ promotion,
771
+ });
772
+ const move = previewChess.makeSanMove(san);
773
+ const fen = previewChess.fen();
774
+ const { halfMoves, fullMoves } = calculatePly(fen);
775
+ solutionPreviewTree?.addNodeToCurrent({
776
+ id: "",
777
+ children: [],
778
+ data: {
779
+ fen,
780
+ san,
781
+ ply: halfMoves,
782
+ fullMoves,
783
+ lastMove: move,
784
+ },
785
+ });
786
+ return fen;
787
+ } catch {
788
+ return "";
789
+ }
790
+ }
791
+ const san = previewChess.getSanForMove({ from, to, promotion });
792
+ const move = tryApplySolverSan(san);
793
+ if (!move) return "";
794
+ return previewChess.fen();
795
+ },
796
+ afterPieceMove: () => {
797
+ if (solved) {
798
+ lastSolverMoveForBoard = null;
799
+ syncAnalysisBoardFromCurrentNode();
800
+ return;
801
+ }
802
+ const move = lastSolverMoveForBoard;
803
+ lastSolverMoveForBoard = null;
804
+ if (move) {
805
+ afterSolverMoveOnBoard(move);
806
+ return;
807
+ }
808
+ syncBoardDragFromChessTurn();
809
+ },
810
+ },
811
+ };
812
+
813
+ let chessboard = $derived.by(() => {
814
+ const merged = buildPuzzleWizardBoardSettings(boardAppearanceSettings);
815
+ return createBoardApi({
816
+ fen: INITIAL_FEN,
817
+ playSettings:
818
+ boardSize !== undefined
819
+ ? { ...merged.play, boardSize }
820
+ : merged.play,
821
+ actions: {
822
+ ...actions,
823
+ onResizeAction,
824
+ },
825
+ });
826
+ });
827
+
828
+ const treeForViewer = $derived(
829
+ solved && solutionPreviewTree
830
+ ? solutionPreviewTree
831
+ : studentPreviewTree,
832
+ );
833
+
834
+ $effect(() => {
835
+ const trimmedPgn = pgn.trim();
836
+ const rootFen = fenProp?.trim() ? fenProp.trim() : INITIAL_FEN;
837
+
838
+ if (!trimmedPgn) {
839
+ untrack(() => {
840
+ layoutInitialFen = rootFen;
841
+ solutionPreviewTree = null;
842
+ previewChess.setFen(rootFen);
843
+ studentPreviewTree.replaceRootTree(
844
+ createEmptyTreeFromFen(rootFen),
845
+ );
846
+ studentPreviewTree.currentNode =
847
+ studentPreviewTree.rootNode.moves;
848
+ chessboard.fen = rootFen;
849
+ chessboard.orientation =
850
+ rootFen.trim().split(/\s+/)[1] === "b"
851
+ ? Color.BLACK
852
+ : Color.WHITE;
853
+ syncMoveEvaluationNagBadge(chessboard, {
854
+ nags: undefined,
855
+ lastMove: null,
856
+ });
857
+ cursorPath = [];
858
+ solved = false;
859
+ wrongFeedback = null;
860
+ syncBoardDragFromChessTurn();
711
861
  });
712
- return {
713
- move,
714
- fen,
715
- turn: previewChess.turn(),
716
- };
717
- } catch {
718
- return undefined;
719
- }
720
- }
721
- const move = tryApplySolverSan(san);
722
- if (!move) return undefined;
723
- return {
724
- move,
725
- fen: previewChess.fen(),
726
- turn: previewChess.turn(),
727
- };
728
- },
729
- afterPieceMoveSan: (move) => {
730
- lastSolverMoveForBoard = null;
731
- if (solved) {
732
- syncAnalysisBoardFromCurrentNode();
733
- return;
862
+ return () => clearTimers();
734
863
  }
735
- afterSolverMoveOnBoard(move);
736
- },
737
- beforePieceMove: (from, to, promotion) => {
738
- if (solved) {
739
- try {
740
- const san = previewChess.getSanForMove({ from, to, promotion });
741
- const move = previewChess.makeSanMove(san);
742
- const fen = previewChess.fen();
743
- const { halfMoves, fullMoves } = calculatePly(fen);
744
- solutionPreviewTree?.addNodeToCurrent({
745
- id: "",
746
- children: [],
747
- data: {
748
- fen,
749
- san,
750
- ply: halfMoves,
751
- fullMoves,
752
- lastMove: move,
753
- },
864
+
865
+ untrack(() => {
866
+ const fullPgn = mergePgnWithSetupFen(trimmedPgn, rootFen);
867
+ const { rootNode } = transformPgnToChessNode(fullPgn);
868
+
869
+ layoutInitialFen = rootFen;
870
+ const solutionTree = new ChessTree(rootNode);
871
+ syncPuzzleBranchNagsOnTree(
872
+ solutionTree,
873
+ rootFen.trim().split(/\s+/)[1] === "b"
874
+ ? Color.BLACK
875
+ : Color.WHITE,
876
+ );
877
+ solutionPreviewTree = solutionTree;
878
+
879
+ cursorPath = [];
880
+ solved = false;
881
+ wrongFeedback = null;
882
+ previewChess.setFen(rootFen);
883
+ studentPreviewTree.replaceRootTree(
884
+ createStudentPreviewTreeFromFen(rootFen, rootNode.comments),
885
+ );
886
+ studentPreviewTree.currentNode = studentPreviewTree.rootNode.moves;
887
+ chessboard.fen = rootFen;
888
+ chessboard.orientation =
889
+ rootFen.trim().split(/\s+/)[1] === "b"
890
+ ? Color.BLACK
891
+ : Color.WHITE;
892
+ syncMoveEvaluationNagBadge(chessboard, {
893
+ nags: undefined,
894
+ lastMove: null,
754
895
  });
755
- return fen;
756
- } catch {
757
- return "";
758
- }
759
- }
760
- const san = previewChess.getSanForMove({ from, to, promotion });
761
- const move = tryApplySolverSan(san);
762
- if (!move) return "";
763
- return previewChess.fen();
764
- },
765
- afterPieceMove: () => {
766
- if (solved) {
767
- lastSolverMoveForBoard = null;
768
- syncAnalysisBoardFromCurrentNode();
769
- return;
896
+ syncBoardDragFromChessTurn();
897
+ });
898
+
899
+ return () => clearTimers();
900
+ });
901
+
902
+ /**
903
+ * Представляет сброс отложенных таймеров превью.
904
+ */
905
+ function clearTimers(): void {
906
+ dismissPendingWrongMoveReveal();
907
+ if (advanceLineTimer !== null) {
908
+ clearTimeout(advanceLineTimer);
909
+ advanceLineTimer = null;
770
910
  }
771
- const move = lastSolverMoveForBoard;
772
- lastSolverMoveForBoard = null;
773
- if (move) {
774
- afterSolverMoveOnBoard(move);
775
- return;
911
+ if (opponentAutoTimer !== null) {
912
+ clearTimeout(opponentAutoTimer);
913
+ opponentAutoTimer = null;
776
914
  }
915
+ }
916
+
917
+ /**
918
+ * Представляет синхронизацию доски с текущим узлом полного дерева решения (после `solved`).
919
+ */
920
+ function syncBoardFromSolutionTreeNode(): void {
921
+ const tree = solutionPreviewTree;
922
+ if (!tree || !solved) return;
923
+
924
+ const node = tree.currentNode;
925
+
926
+ previewChess.setFen(node.data.fen);
927
+ chessboard.fen = previewChess.fen();
928
+ chessboard.addLastMove(node.data.lastMove ?? null);
929
+ syncMoveEvaluationNagBadge(
930
+ chessboard,
931
+ nodeDataForAnalysisBoardBadge(node.data),
932
+ );
933
+ chessboard.setNodeMarkers(node.data.parsedFirstComment?.shapes);
777
934
  syncBoardDragFromChessTurn();
778
- },
779
- },
780
- };
781
-
782
- let chessboard = $derived.by(() => {
783
- const merged = buildPuzzleWizardBoardSettings(boardAppearanceSettings);
784
- return createBoardApi({
785
- fen: INITIAL_FEN,
786
- playSettings:
787
- boardSize !== undefined
788
- ? { ...merged.play, boardSize }
789
- : merged.play,
790
- actions: {
791
- ...actions,
792
- onResizeAction,
793
- },
794
- });
795
- });
796
-
797
- const chessboardDesign = $derived({
798
- ...buildPuzzleWizardBoardSettings(boardAppearanceSettings).design,
799
- theme: boardTheme ?? CHESSBOARD_THEMES.blue,
800
- });
801
-
802
- const treeForViewer = $derived(
803
- solved && solutionPreviewTree ? solutionPreviewTree : studentPreviewTree,
804
- );
805
-
806
- $effect(() => {
807
- const trimmedPgn = pgn.trim();
808
- const rootFen = fenProp?.trim() ? fenProp.trim() : INITIAL_FEN;
809
-
810
- if (!trimmedPgn) {
811
- untrack(() => {
812
- layoutInitialFen = rootFen;
813
- solutionPreviewTree = null;
814
- previewChess.setFen(rootFen);
815
- studentPreviewTree.replaceRootTree(createEmptyTreeFromFen(rootFen));
816
- studentPreviewTree.currentNode = studentPreviewTree.rootNode.moves;
817
- chessboard.fen = rootFen;
818
- chessboard.orientation =
819
- rootFen.trim().split(/\s+/)[1] === "b"
820
- ? Color.BLACK
821
- : Color.WHITE;
822
- syncMoveEvaluationNagBadge(chessboard, {
823
- nags: undefined,
824
- lastMove: null,
825
- });
826
- cursorPath = [];
827
- solved = false;
828
- wrongFeedback = null;
829
- syncBoardDragFromChessTurn();
830
- });
831
- return () => clearTimers();
832
935
  }
833
936
 
834
- untrack(() => {
835
- const fullPgn = mergePgnWithSetupFen(trimmedPgn, rootFen);
836
- const { rootNode } = transformPgnToChessNode(fullPgn);
837
-
838
- layoutInitialFen = rootFen;
839
- const solutionTree = new ChessTree(rootNode);
840
- syncPuzzleBranchNagsOnTree(
841
- solutionTree,
842
- rootFen.trim().split(/\s+/)[1] === "b" ? Color.BLACK : Color.WHITE,
843
- );
844
- solutionPreviewTree = solutionTree;
845
-
846
- cursorPath = [];
847
- solved = false;
848
- wrongFeedback = null;
849
- previewChess.setFen(rootFen);
850
- studentPreviewTree.replaceRootTree(
851
- createStudentPreviewTreeFromFen(rootFen, rootNode.comments),
852
- );
853
- studentPreviewTree.currentNode = studentPreviewTree.rootNode.moves;
854
- chessboard.fen = rootFen;
855
- chessboard.orientation =
856
- rootFen.trim().split(/\s+/)[1] === "b" ? Color.BLACK : Color.WHITE;
857
- syncMoveEvaluationNagBadge(chessboard, {
858
- nags: undefined,
859
- lastMove: null,
860
- });
861
- syncBoardDragFromChessTurn();
862
- });
937
+ /**
938
+ * Представляет установку FEN движка при навигации по дереву (только после полного решения превью).
939
+ */
940
+ function treeSetChessFen(fen: string): void {
941
+ if (!solved) return;
942
+ previewChess.setFen(fen);
943
+ }
863
944
 
864
- return () => clearTimers();
865
- });
866
-
867
- /**
868
- * Представляет сброс отложенных таймеров превью.
869
- */
870
- function clearTimers(): void {
871
- dismissPendingWrongMoveReveal();
872
- if (advanceLineTimer !== null) {
873
- clearTimeout(advanceLineTimer);
874
- advanceLineTimer = null;
945
+ /**
946
+ * Представляет обновление UI доски после смены узла в дереве решения.
947
+ */
948
+ function treeSetChessboardFen(_animationTime: number | undefined): void {
949
+ if (!solved) return;
950
+ syncBoardFromSolutionTreeNode();
875
951
  }
876
- if (opponentAutoTimer !== null) {
877
- clearTimeout(opponentAutoTimer);
878
- opponentAutoTimer = null;
952
+
953
+ /**
954
+ * Представляет реакцию на выбор узла в дереве (клик по ходу после решения).
955
+ */
956
+ function treeOnSelectNode(): void {
957
+ if (!solved) return;
958
+ syncBoardFromSolutionTreeNode();
959
+ }
960
+
961
+ /**
962
+ * Представляет синхронизацию доски после операций с вариантами (в режиме просмотра без правки не используется).
963
+ */
964
+ function treeOnDeleteVariant(): void {
965
+ if (!solved) return;
966
+ syncBoardFromSolutionTreeNode();
879
967
  }
880
- }
881
-
882
- /**
883
- * Представляет синхронизацию доски с текущим узлом полного дерева решения (после `solved`).
884
- */
885
- function syncBoardFromSolutionTreeNode(): void {
886
- const tree = solutionPreviewTree;
887
- if (!tree || !solved) return;
888
-
889
- const node = tree.currentNode;
890
-
891
- previewChess.setFen(node.data.fen);
892
- chessboard.fen = previewChess.fen();
893
- chessboard.addLastMove(node.data.lastMove ?? null);
894
- syncMoveEvaluationNagBadge(
895
- chessboard,
896
- nodeDataForAnalysisBoardBadge(node.data),
897
- );
898
- chessboard.setNodeMarkers(node.data.parsedFirstComment?.shapes);
899
- syncBoardDragFromChessTurn();
900
- }
901
-
902
- /**
903
- * Представляет установку FEN движка при навигации по дереву (только после полного решения превью).
904
- */
905
- function treeSetChessFen(fen: string): void {
906
- if (!solved) return;
907
- previewChess.setFen(fen);
908
- }
909
-
910
- /**
911
- * Представляет обновление UI доски после смены узла в дереве решения.
912
- */
913
- function treeSetChessboardFen(_animationTime: number | undefined): void {
914
- if (!solved) return;
915
- syncBoardFromSolutionTreeNode();
916
- }
917
-
918
- /**
919
- * Представляет реакцию на выбор узла в дереве (клик по ходу после решения).
920
- */
921
- function treeOnSelectNode(): void {
922
- if (!solved) return;
923
- syncBoardFromSolutionTreeNode();
924
- }
925
-
926
- /**
927
- * Представляет синхронизацию доски после операций с вариантами (в режиме просмотра без правки не используется).
928
- */
929
- function treeOnDeleteVariant(): void {
930
- if (!solved) return;
931
- syncBoardFromSolutionTreeNode();
932
- }
933
968
  </script>
934
969
 
935
970
  <div class={cn("flex w-full flex-col gap-2", className)}>
936
- <PuzzleBoardTreeViewerPane
937
- {chessboard}
938
- {chessboardDesign}
939
- chessTree={treeForViewer}
940
- onSelectNode={treeOnSelectNode}
941
- onDeleteVariant={treeOnDeleteVariant}
942
- setChessFen={treeSetChessFen}
943
- setChessboardFen={treeSetChessboardFen}
944
- editMode={false}
945
- selectable={solved}
946
- >
947
- {#snippet belowChessboard()}
948
- {#if wrongFeedback && wrongFeedback !== PREVIEW_WRONG_NO_VARIANT_MESSAGE}
949
- <p class="text-sm font-medium text-amber-600 dark:text-amber-400">
950
- {wrongFeedback}
951
- </p>
952
- {/if}
953
- {/snippet}
954
- </PuzzleBoardTreeViewerPane>
971
+ <PuzzleBoardTreeViewerPane
972
+ {chessboard}
973
+ chessTree={treeForViewer}
974
+ onSelectNode={treeOnSelectNode}
975
+ onDeleteVariant={treeOnDeleteVariant}
976
+ setChessFen={treeSetChessFen}
977
+ setChessboardFen={treeSetChessboardFen}
978
+ editMode={false}
979
+ selectable={solved}
980
+ >
981
+ {#snippet belowChessboard()}
982
+ {#if wrongFeedback && wrongFeedback !== PREVIEW_WRONG_NO_VARIANT_MESSAGE}
983
+ <p
984
+ class="text-sm font-medium text-amber-600 dark:text-amber-400"
985
+ >
986
+ {wrongFeedback}
987
+ </p>
988
+ {/if}
989
+ {/snippet}
990
+ </PuzzleBoardTreeViewerPane>
955
991
  </div>