@connectorvol/chess-widgets 8.0.1 → 9.0.1

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.
Files changed (48) hide show
  1. package/dist/(components)/BoardSettingsTrigger.svelte +10 -0
  2. package/dist/(components)/BoardSettingsTrigger.svelte.d.ts +18 -0
  3. package/dist/constants/default-board-appearance-settings.d.ts +9 -0
  4. package/dist/constants/default-board-appearance-settings.js +30 -0
  5. package/dist/constants/editable-board-settings.d.ts +3 -21
  6. package/dist/constants/editable-board-settings.js +24 -19
  7. package/dist/game-analyzer/GameAnalyzer.svelte +28 -10
  8. package/dist/game-analyzer/gameAnalyzer.svelte.js +6 -9
  9. package/dist/game-analyzer/types.d.ts +13 -8
  10. package/dist/index.d.ts +9 -1
  11. package/dist/index.js +6 -0
  12. package/dist/position-editor/EditPanel.svelte +9 -6
  13. package/dist/puzzle/puzzleCreatedPayload.d.ts +17 -0
  14. package/dist/puzzle/puzzleData.d.ts +4 -0
  15. package/dist/puzzle/puzzleData.js +10 -0
  16. package/dist/puzzle/puzzlePreviewConstants.d.ts +4 -0
  17. package/dist/puzzle/puzzlePreviewConstants.js +4 -0
  18. package/dist/puzzle/puzzlePreviewPathNags.d.ts +6 -0
  19. package/dist/puzzle/puzzlePreviewPathNags.js +35 -0
  20. package/dist/puzzle/puzzleSolverForkAnnotations.d.ts +2 -4
  21. package/dist/puzzle/puzzleSolverForkAnnotations.js +13 -22
  22. package/dist/puzzle/puzzleStepPreviewSolver.d.ts +13 -1
  23. package/dist/puzzle/puzzleStepPreviewSolver.js +69 -9
  24. package/dist/puzzle/syncPuzzleBranchNags.d.ts +14 -0
  25. package/dist/puzzle/syncPuzzleBranchNags.js +125 -0
  26. package/dist/puzzle-creation/OpeningTagHoverPreview.svelte +81 -0
  27. package/dist/puzzle-creation/OpeningTagHoverPreview.svelte.d.ts +11 -0
  28. package/dist/puzzle-creation/PuzzleBoardTreeViewerPane.svelte +198 -98
  29. package/dist/puzzle-creation/PuzzleBoardTreeViewerPane.svelte.d.ts +20 -2
  30. package/dist/puzzle-creation/PuzzleCreationWizard.svelte +100 -106
  31. package/dist/puzzle-creation/PuzzleCreationWizard.svelte.d.ts +47 -3
  32. package/dist/puzzle-creation/PuzzlePgnBoardTreeEditor.svelte +945 -498
  33. package/dist/puzzle-creation/PuzzleWizardTagsStep.svelte +36 -0
  34. package/dist/puzzle-creation/PuzzleWizardTagsStep.svelte.d.ts +12 -0
  35. package/dist/puzzle-creation/StepMoves.svelte +210 -188
  36. package/dist/puzzle-creation/StepPosition.svelte +35 -9
  37. package/dist/puzzle-creation/StepPreview.svelte +24 -11
  38. package/dist/puzzle-creation/StepPreview.svelte.d.ts +3 -1
  39. package/dist/puzzle-creation/StepTags.svelte +270 -0
  40. package/dist/puzzle-creation/StepTags.svelte.d.ts +19 -0
  41. package/dist/puzzle-creation/buildPuzzleWizardBoardSettings.d.ts +9 -0
  42. package/dist/puzzle-creation/buildPuzzleWizardBoardSettings.js +19 -0
  43. package/dist/puzzle-creation/createPuzzleLineEditingBoard.d.ts +10 -3
  44. package/dist/puzzle-creation/createPuzzleLineEditingBoard.js +14 -9
  45. package/dist/puzzle-creation/puzzleWizardState.d.ts +35 -0
  46. package/dist/puzzle-creation/puzzleWizardState.js +37 -0
  47. package/dist/puzzle-creation/types.d.ts +36 -10
  48. package/package.json +22 -17
@@ -1,544 +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 { Color, calculatePly, type Move } from "@connectorvol/shared";
27
-
28
- import { mergePgnWithSetupFen } from "../puzzle/mergePgnWithSetupFen.js";
29
- import { createEmptyTreeFromFen } from "../puzzle/puzzleData.js";
30
- import { PREVIEW_WRONG_NO_VARIANT_MESSAGE } from "../puzzle/puzzlePreviewConstants.js";
31
- import {
32
- puzzlePreviewClassifySolverMove,
33
- puzzlePreviewDupSubtreeUnderStudentCursor,
34
- puzzlePreviewIsSolvedPosition,
35
- puzzlePreviewNodeAtPath,
36
- puzzlePreviewSideToMoveFromFen,
37
- type TSolverMoveOutcome,
38
- } from "../puzzle/puzzleStepPreviewSolver.js";
39
- import PuzzleBoardTreeViewerPane from "./PuzzleBoardTreeViewerPane.svelte";
40
- import { cn } from "../utils.js";
41
- import type { TPuzzlePgnBoardTreeEditorProps } from "./types.js";
42
-
43
- let {
44
- pgn = "",
45
- fen: fenProp,
46
- onOutcome,
47
- boardTheme,
48
- boardAppearanceSettings,
49
- class: className,
50
- wrongFeedback = $bindable<string | null>(null),
51
- solved = $bindable(false),
52
- }: TPuzzlePgnBoardTreeEditorProps = $props();
53
-
54
- /** Корень превью: синхронизируется с эффектом смены PGN/FEN. */
55
- let layoutInitialFen = $state(INITIAL_FEN);
56
-
57
- /**
58
- * Представляет цвет стороны решателя по текущему корню превью (`layoutInitialFen`).
59
- */
60
- function puzzleSolverColor(): Color {
61
- return layoutInitialFen.trim().split(/\s+/)[1] === "b"
62
- ? Color.BLACK
63
- : Color.WHITE;
64
- }
65
-
66
- let cursorPath = $state<number[]>([]);
67
-
68
- let wrongRevealTimer: ReturnType<typeof setTimeout> | null = null;
69
- let advanceLineTimer: ReturnType<typeof setTimeout> | null = null;
70
-
71
- let previewChess = new PgnOps(INITIAL_FEN, "chess");
72
-
73
- const studentPreviewTree = new ChessTree(createEmptyTreeFromFen(INITIAL_FEN));
74
-
75
- let solutionPreviewTree = $state<ChessTree | null>(null);
76
-
77
- /**
78
- * Представляет синхронизацию доступности перетаскивания с очередью хода и состоянием «решено».
79
- */
80
- function syncBoardDragFromChessTurn(): void {
81
- const solver = puzzleSolverColor();
82
- chessboard.draggable = solved
83
- ? previewChess.turn() === Color.WHITE
84
- ? Draggable.WHITE
85
- : Draggable.BLACK
86
- : previewChess.turn() === solver
87
- ? solver === Color.WHITE
88
- ? Draggable.WHITE
89
- : Draggable.BLACK
90
- : Draggable.NONE;
91
- }
92
-
93
- /**
94
- * Представляет добавление пути в дерево ученика без очистки уже пройденных линий.
95
- */
96
- function ensureStudentPreviewTreeHasPath(path: number[]): void {
97
- const solution = solutionPreviewTree;
98
- if (!solution) return;
99
-
100
- studentPreviewTree.currentNode = studentPreviewTree.rootNode.moves;
101
-
102
- let src = solution.rootNode.moves;
103
- for (const idx of path) {
104
- const next = src.children[idx];
105
- if (!next) break;
106
- studentPreviewTree.addNodeToCurrent({
107
- id: "",
108
- children: [],
109
- data: { ...next.data },
110
- });
111
- src = next;
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;
112
83
  }
113
- }
114
-
115
- /**
116
- * Представляет обработку ошибочного хода в превью.
117
- */
118
- function handleWrongOutcome(outcome: TSolverMoveOutcome): void {
119
- const solution = solutionPreviewTree;
120
- if (!solution) return;
121
-
122
- if (wrongRevealTimer !== null) {
123
- clearTimeout(wrongRevealTimer);
124
- wrongRevealTimer = null;
84
+
85
+ let cursorPath = $state<number[]>([]);
86
+
87
+ const OPPONENT_MOVE_ANIMATION_MS = 300;
88
+
89
+ let wrongRevealTimer: ReturnType<typeof setTimeout> | null = null;
90
+ let advanceLineTimer: ReturnType<typeof setTimeout> | null = null;
91
+ let opponentAutoTimer: ReturnType<typeof setTimeout> | null = null;
92
+
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
+ };
104
+
105
+ let pendingWrongMoveReveal = $state<TPendingWrongMoveReveal | null>(null);
106
+
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),
114
+ );
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;
125
137
  }
126
- if (advanceLineTimer !== null) {
127
- clearTimeout(advanceLineTimer);
128
- advanceLineTimer = null;
138
+
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();
129
160
  }
130
161
 
131
- if (outcome.kind === "wrong_marked_variant") {
132
- wrongFeedback =
133
- "Неверно. Открываю введённый вариант в дереве, ход отменён.";
134
- onOutcome?.("failed");
135
- ensureStudentPreviewTreeHasPath(cursorPath);
136
- puzzlePreviewDupSubtreeUnderStudentCursor(
137
- studentPreviewTree,
138
- outcome.wrongBranchRoot,
139
- );
140
- return;
162
+ /**
163
+ * Представляет досрочное завершение показа ошибочного хода перед следующим ходом решателя.
164
+ */
165
+ function cancelPendingWrongMoveReveal(): void {
166
+ if (pendingWrongMoveReveal === null) return;
167
+
168
+ if (wrongRevealTimer !== null) {
169
+ clearTimeout(wrongRevealTimer);
170
+ wrongRevealTimer = null;
171
+ }
172
+
173
+ finishWrongMoveReveal();
141
174
  }
142
175
 
143
- wrongFeedback = PREVIEW_WRONG_NO_VARIANT_MESSAGE;
144
- onOutcome?.("failed");
145
- }
146
-
147
- /**
148
- * Представляет установку позиции и дерева ученика по пути из корня.
149
- */
150
- function setStateFromPath(path: number[]): void {
151
- const solution = solutionPreviewTree;
152
- if (!solution) return;
153
-
154
- previewChess.setFen(layoutInitialFen);
155
- let node = solution.rootNode.moves;
156
- for (const idx of path) {
157
- const child = node.children[idx];
158
- if (!child) break;
159
- previewChess.makeSanMove(child.data.san);
160
- node = child;
176
+ /**
177
+ * Представляет показ NAG ✗ на доске и отложенный откат ошибочного хода.
178
+ */
179
+ function scheduleWrongMoveReveal(wrongMove: Move): void {
180
+ const pending = pendingWrongMoveReveal;
181
+ if (pending === null) return;
182
+
183
+ syncMoveEvaluationNagBadge(chessboard, {
184
+ nags: [PUZZLE_BRANCH_WRONG_NAG_ID],
185
+ lastMove: wrongMove,
186
+ });
187
+ syncBoardDragFromChessTurn();
188
+
189
+ wrongRevealTimer = setTimeout(() => {
190
+ wrongRevealTimer = null;
191
+ finishWrongMoveReveal();
192
+ }, PREVIEW_WRONG_MOVE_REVEAL_MS);
161
193
  }
162
194
 
163
- cursorPath = path;
164
- ensureStudentPreviewTreeHasPath(cursorPath);
165
- chessboard.fen = previewChess.fen();
166
- syncBoardDragFromChessTurn();
167
- }
168
-
169
- /**
170
- * Представляет автоматический ход соперника по главному варианту после хода решателя.
171
- */
172
- function applyOpponentMainLineMove(pathAfterSolver: number[]): number[] {
173
- const solution = solutionPreviewTree;
174
- if (!solution) return pathAfterSolver;
175
-
176
- if (puzzlePreviewSideToMoveFromFen(previewChess.fen()) === puzzleSolverColor()) {
177
- return pathAfterSolver;
195
+ /**
196
+ * Представляет завершение показа ошибочного хода: запись варианта в дерево и откат позиции на доске.
197
+ */
198
+ function finishWrongMoveReveal(): void {
199
+ const pending = pendingWrongMoveReveal;
200
+ pendingWrongMoveReveal = null;
201
+ if (pending === null) return;
202
+
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();
178
213
  }
179
214
 
180
- const node = puzzlePreviewNodeAtPath(
181
- solution.rootNode.moves,
182
- pathAfterSolver,
183
- );
184
- if (!node || node.children.length === 0) return pathAfterSolver;
185
-
186
- previewChess.makeSanMove(node.children[0]!.data.san);
187
- return [...pathAfterSolver, 0];
188
- }
189
-
190
- /**
191
- * Представляет поиск следующей линии по развилкам соперника (DFS: чем глубже развилка тем раньше возврат).
192
- */
193
- function findNextOpponentLine(path: number[]): number[] | null {
194
- const solution = solutionPreviewTree;
195
- if (!solution) return null;
196
-
197
- const solver = puzzleSolverColor();
198
-
199
- let node = solution.rootNode.moves;
200
- for (let depth = 0; depth <= path.length; depth++) {
201
- const sideToMove = puzzlePreviewSideToMoveFromFen(node.data.fen);
202
- const isOpponentToMove = sideToMove !== solver;
203
- const isFork = node.children.length > 1;
204
-
205
- if (isOpponentToMove && isFork && depth < path.length) {
206
- const chosenIdx = path[depth]!;
207
- if (chosenIdx < node.children.length - 1) {
208
- /* кандидат на «следующую линию» с этого форка — ниже второй проход */
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
+ };
237
+ }
238
+
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
+ }
255
+
256
+ /**
257
+ * Представляет обработку UI после применения хода решателя на доске.
258
+ */
259
+ function afterSolverMoveOnBoard(move: Move): void {
260
+ if (pendingWrongMoveReveal !== null) {
261
+ scheduleWrongMoveReveal(move);
262
+ return;
209
263
  }
210
- }
211
264
 
212
- if (depth === path.length) break;
213
- const child = node.children[path[depth]!];
214
- if (!child) break;
215
- node = child;
265
+ syncCorrectSolverMoveNagBadge(move);
266
+ syncBoardDragFromChessTurn();
267
+ if (!solved) {
268
+ scheduleOpponentAutoResponse();
269
+ }
216
270
  }
217
271
 
218
- node = solution.rootNode.moves;
219
- let bestForkDepth: number | null = null;
220
- let bestNextIdx = 0;
221
- for (let depth = 0; depth < path.length; depth++) {
222
- const sideToMove = puzzlePreviewSideToMoveFromFen(node.data.fen);
223
- const isOpponentToMove = sideToMove !== solver;
224
- if (isOpponentToMove && node.children.length > 1) {
225
- const chosenIdx = path[depth]!;
226
- if (chosenIdx < node.children.length - 1) {
227
- bestForkDepth = depth;
228
- bestNextIdx = chosenIdx + 1;
272
+ /**
273
+ * Представляет добавление пути в дерево ученика без очистки уже пройденных линий.
274
+ */
275
+ function ensureStudentPreviewTreeHasPath(path: number[]): void {
276
+ const solution = solutionPreviewTree;
277
+ if (!solution) return;
278
+
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;
229
310
  }
230
- }
231
- const child = node.children[path[depth]!];
232
- if (!child) break;
233
- node = child;
311
+
312
+ const solverColor = puzzleSolverColor();
313
+ normalizePuzzleTreeMainLine(
314
+ studentPreviewTree.rootNode.moves,
315
+ solverColor,
316
+ );
234
317
  }
235
318
 
236
- if (bestForkDepth === null) return null;
237
- return [...path.slice(0, bestForkDepth), bestNextIdx];
238
- }
239
-
240
- /**
241
- * Представляет переход на следующую линию ответов соперника, если возможно.
242
- */
243
- function maybeAdvanceToNextOpponentLine(): boolean {
244
- const nextBase = findNextOpponentLine(cursorPath);
245
- if (!nextBase) return false;
246
-
247
- solved = false;
248
- wrongFeedback = null;
249
-
250
- setStateFromPath(nextBase);
251
- return true;
252
- }
253
-
254
- /**
255
- * Представляет попытку применить SAN хода решателя с автоответами соперника.
256
- */
257
- function tryApplySolverSan(san: string): Move | undefined {
258
- const solution = solutionPreviewTree;
259
- if (!solution || solved) return undefined;
260
-
261
- const cursorNode = puzzlePreviewNodeAtPath(
262
- solution.rootNode.moves,
263
- cursorPath,
264
- );
265
- if (!cursorNode) return undefined;
319
+ /**
320
+ * Представляет нормализацию главной линии и NAG только в дереве решения (PGN).
321
+ */
322
+ function normalizeSolutionTree(): void {
323
+ const solution = solutionPreviewTree;
324
+ if (!solution) return;
266
325
 
267
- const outcome = puzzlePreviewClassifySolverMove(
268
- cursorNode,
269
- previewChess.fen(),
270
- san,
271
- puzzleSolverColor(),
272
- );
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
+ );
334
+ }
273
335
 
274
- if (outcome.kind !== "correct") {
275
- handleWrongOutcome(outcome);
276
- return undefined;
336
+ /**
337
+ * Представляет синхронизацию дерева ученика с пройденным путём решения (после верного хода).
338
+ */
339
+ function syncStudentTreeFromSolutionPath(path: number[]): void {
340
+ ensureStudentPreviewTreeHasPath(path);
341
+ studentPreviewTree.forcedNodeId = null;
342
+ studentPreviewTree.mutationVersion++;
277
343
  }
278
344
 
279
- try {
280
- let path = [...cursorPath, outcome.childIndex];
281
- const moveRecord = previewChess.makeSanMove(san);
282
- path = applyOpponentMainLineMove(path);
283
- setStateFromPath(path);
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;
381
+ }
284
382
 
285
- const leaf = puzzlePreviewNodeAtPath(solution.rootNode.moves, cursorPath);
286
- if (leaf && puzzlePreviewIsSolvedPosition(previewChess, leaf, puzzleSolverColor())) {
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
+ }
287
397
  if (advanceLineTimer !== null) {
288
- clearTimeout(advanceLineTimer);
289
- 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;
290
428
  }
291
- advanceLineTimer = setTimeout(() => {
292
- const advanced = maybeAdvanceToNextOpponentLine();
293
- if (!advanced) {
294
- solved = true;
295
- onOutcome?.("solved");
296
- }
297
- syncBoardDragFromChessTurn();
298
- advanceLineTimer = null;
299
- }, 250);
300
- }
301
-
302
- wrongFeedback = null;
303
- syncBoardDragFromChessTurn();
304
-
305
- return moveRecord;
306
- } catch {
307
- wrongFeedback = "Ход недопустим в этой позиции.";
308
- onOutcome?.("failed");
309
- return undefined;
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
+
441
+ wrongFeedback = PREVIEW_WRONG_NO_VARIANT_MESSAGE;
442
+ onOutcome?.("failed");
310
443
  }
311
- }
312
-
313
- const actions: IChessBoardActions = {
314
- game: {
315
- possibleMovesOnSquare: (square: Square) => previewChess.moves(square),
316
- beforePieceMoveSan(san: string) {
317
- if (solved) {
318
- try {
319
- const move = previewChess.makeSanMove(san);
320
- const fen = previewChess.fen();
321
- const { halfMoves, fullMoves } = calculatePly(fen);
322
- solutionPreviewTree?.addNodeToCurrent({
323
- id: "",
324
- children: [],
325
- data: {
326
- fen,
327
- san,
328
- ply: halfMoves,
329
- fullMoves,
330
- lastMove: move,
331
- },
332
- });
333
- return {
334
- move,
335
- fen,
336
- turn: previewChess.turn(),
337
- };
338
- } catch {
339
- return undefined;
340
- }
444
+
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();
473
+ }
474
+
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;
341
489
  }
342
- const move = tryApplySolverSan(san);
343
- if (!move) return undefined;
490
+
491
+ const node = puzzlePreviewNodeAtPath(
492
+ solution.rootNode.moves,
493
+ pathAfterSolver,
494
+ );
495
+ if (!node || node.children.length === 0) return null;
496
+
344
497
  return {
345
- move,
346
- fen: previewChess.fen(),
347
- turn: previewChess.turn(),
498
+ san: node.children[0]!.data.san,
499
+ path: [...pathAfterSolver, 0],
348
500
  };
349
- },
350
- afterPieceMoveSan: () => {
351
- syncBoardDragFromChessTurn();
352
- },
353
- beforePieceMove: (from, to, promotion) => {
354
- if (solved) {
355
- try {
356
- const san = previewChess.getSanForMove({ from, to, promotion });
357
- const move = previewChess.makeSanMove(san);
358
- const fen = previewChess.fen();
359
- const { halfMoves, fullMoves } = calculatePly(fen);
360
- solutionPreviewTree?.addNodeToCurrent({
361
- id: "",
362
- children: [],
363
- data: {
364
- fen,
365
- san,
366
- ply: halfMoves,
367
- fullMoves,
368
- lastMove: move,
369
- },
370
- });
371
- return fen;
372
- } catch {
373
- return "";
374
- }
501
+ }
502
+
503
+ /**
504
+ * Представляет проверку конца линии и переход к следующему варианту ответа соперника.
505
+ */
506
+ function checkSolvedAndMaybeAdvance(): void {
507
+ const solution = solutionPreviewTree;
508
+ if (!solution) return;
509
+
510
+ const leaf = puzzlePreviewNodeAtPath(
511
+ solution.rootNode.moves,
512
+ cursorPath,
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);
541
+ }
542
+ }
543
+
544
+ /**
545
+ * Представляет анимированный автоматический ход соперника после хода решателя.
546
+ */
547
+ function playOpponentAutoResponse(pathAfterSolver: number[]): void {
548
+ const reply = getOpponentMainLineReply(pathAfterSolver);
549
+ if (!reply) {
550
+ checkSolvedAndMaybeAdvance();
551
+ return;
552
+ }
553
+
554
+ let opponentMove: Move;
555
+ try {
556
+ opponentMove = previewChess.makeSanMove(reply.san);
557
+ } catch {
558
+ return;
375
559
  }
376
- const san = previewChess.getSanForMove({ from, to, promotion });
377
- const move = tryApplySolverSan(san);
378
- if (!move) return "";
379
- return previewChess.fen();
380
- },
381
- afterPieceMove: () => {
560
+
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
+ });
382
571
  syncBoardDragFromChessTurn();
383
- },
384
- },
385
- };
386
-
387
- let chessboard = $derived(
388
- createBoardApi({
389
- fen: INITIAL_FEN,
390
- settings: {
391
- ...boardAppearanceSettings,
392
- boardSize: 38,
393
- isResizable: false,
394
- orientation: Color.WHITE,
395
- draggable: Draggable.WHITE,
396
- },
397
- actions,
398
- theme: boardTheme ?? CHESSBOARD_THEMES.blue,
399
- }),
400
- );
401
-
402
- const treeForViewer = $derived(
403
- solved && solutionPreviewTree ? solutionPreviewTree : studentPreviewTree,
404
- );
405
-
406
- /**
407
- * Представляет синхронизацию выбранного узла в полном дереве решения с пройденной линией при переходе в режим просмотра (`solved`).
408
- * Без этого после switch с `studentPreviewTree` у `solutionPreviewTree` остаётся `currentNode` у корня и подсветка в TreeViewer неверна.
409
- */
410
- $effect(() => {
411
- if (!solved || !solutionPreviewTree) return;
412
- const tree = solutionPreviewTree;
413
- const path = cursorPath;
414
- const target = puzzlePreviewNodeAtPath(tree.rootNode.moves, path);
415
- tree.currentNode = target ?? tree.rootNode.moves;
416
- });
417
-
418
- $effect(() => {
419
- const trimmedPgn = pgn.trim();
420
- const rootFen = fenProp?.trim() ? fenProp.trim() : INITIAL_FEN;
421
-
422
- if (!trimmedPgn) {
423
- untrack(() => {
424
- layoutInitialFen = rootFen;
425
- solutionPreviewTree = null;
426
- previewChess.setFen(rootFen);
427
- studentPreviewTree.replaceRootTree(createEmptyTreeFromFen(rootFen));
428
- studentPreviewTree.currentNode = studentPreviewTree.rootNode.moves;
429
- chessboard.fen = rootFen;
430
- chessboard.orientation =
431
- rootFen.trim().split(/\s+/)[1] === "b"
432
- ? Color.BLACK
433
- : Color.WHITE;
434
- cursorPath = [];
572
+
573
+ checkSolvedAndMaybeAdvance();
574
+ }
575
+
576
+ /**
577
+ * Представляет отложенный автоматический ответ соперника после анимации хода решателя.
578
+ */
579
+ function scheduleOpponentAutoResponse(): void {
580
+ if (opponentAutoTimer !== null) {
581
+ clearTimeout(opponentAutoTimer);
582
+ opponentAutoTimer = null;
583
+ }
584
+
585
+ const pathAfterSolver = [...cursorPath];
586
+ const delayMs = chessboard.animationTime || OPPONENT_MOVE_ANIMATION_MS;
587
+ opponentAutoTimer = setTimeout(() => {
588
+ playOpponentAutoResponse(pathAfterSolver);
589
+ opponentAutoTimer = null;
590
+ }, delayMs);
591
+ }
592
+
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;
619
+ }
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];
641
+ }
642
+
643
+ /**
644
+ * Представляет переход на следующую линию ответов соперника, если возможно.
645
+ */
646
+ function maybeAdvanceToNextOpponentLine(): boolean {
647
+ const nextBase = findNextOpponentLine(cursorPath);
648
+ if (!nextBase) return false;
649
+
435
650
  solved = false;
436
651
  wrongFeedback = null;
437
- syncBoardDragFromChessTurn();
438
- });
439
- return () => clearTimers();
652
+
653
+ setStateFromPath(nextBase);
654
+ return true;
440
655
  }
441
656
 
442
- untrack(() => {
443
- const fullPgn = mergePgnWithSetupFen(trimmedPgn, rootFen);
444
- const { rootNode } = transformPgnToChessNode(fullPgn);
445
-
446
- layoutInitialFen = rootFen;
447
- solutionPreviewTree = new ChessTree(rootNode);
448
-
449
- cursorPath = [];
450
- solved = false;
451
- wrongFeedback = null;
452
- previewChess.setFen(rootFen);
453
- studentPreviewTree.replaceRootTree(createEmptyTreeFromFen(rootFen));
454
- studentPreviewTree.currentNode = studentPreviewTree.rootNode.moves;
455
- chessboard.fen = rootFen;
456
- chessboard.orientation =
457
- rootFen.trim().split(/\s+/)[1] === "b" ? Color.BLACK : Color.WHITE;
458
- syncBoardDragFromChessTurn();
657
+ /**
658
+ * Представляет попытку применить SAN хода решателя с автоответами соперника.
659
+ */
660
+ function tryApplySolverSan(san: string): Move | undefined {
661
+ const solution = solutionPreviewTree;
662
+ if (!solution || solved) return undefined;
663
+
664
+ cancelPendingWrongMoveReveal();
665
+
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
+ }
699
+
700
+ try {
701
+ const pathAfterSolver = [...cursorPath, outcome.childIndex];
702
+ const moveRecord = previewChess.makeSanMove(san);
703
+ cursorPath = pathAfterSolver;
704
+ syncStudentTreeFromSolutionPath(pathAfterSolver);
705
+
706
+ wrongFeedback = null;
707
+ lastSolverMoveForBoard = moveRecord;
708
+ syncBoardDragFromChessTurn();
709
+
710
+ return moveRecord;
711
+ } catch {
712
+ wrongFeedback = "Ход недопустим в этой позиции.";
713
+ onOutcome?.("failed");
714
+ return undefined;
715
+ }
716
+ }
717
+
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();
861
+ });
862
+ return () => clearTimers();
863
+ }
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,
895
+ });
896
+ syncBoardDragFromChessTurn();
897
+ });
898
+
899
+ return () => clearTimers();
459
900
  });
460
901
 
461
- return () => clearTimers();
462
- });
902
+ /**
903
+ * Представляет сброс отложенных таймеров превью.
904
+ */
905
+ function clearTimers(): void {
906
+ dismissPendingWrongMoveReveal();
907
+ if (advanceLineTimer !== null) {
908
+ clearTimeout(advanceLineTimer);
909
+ advanceLineTimer = null;
910
+ }
911
+ if (opponentAutoTimer !== null) {
912
+ clearTimeout(opponentAutoTimer);
913
+ opponentAutoTimer = null;
914
+ }
915
+ }
463
916
 
464
- /**
465
- * Представляет сброс отложенных таймеров превью.
466
- */
467
- function clearTimers(): void {
468
- if (wrongRevealTimer !== null) {
469
- clearTimeout(wrongRevealTimer);
470
- wrongRevealTimer = null;
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);
934
+ syncBoardDragFromChessTurn();
471
935
  }
472
- if (advanceLineTimer !== null) {
473
- clearTimeout(advanceLineTimer);
474
- advanceLineTimer = null;
936
+
937
+ /**
938
+ * Представляет установку FEN движка при навигации по дереву (только после полного решения превью).
939
+ */
940
+ function treeSetChessFen(fen: string): void {
941
+ if (!solved) return;
942
+ previewChess.setFen(fen);
943
+ }
944
+
945
+ /**
946
+ * Представляет обновление UI доски после смены узла в дереве решения.
947
+ */
948
+ function treeSetChessboardFen(_animationTime: number | undefined): void {
949
+ if (!solved) return;
950
+ syncBoardFromSolutionTreeNode();
951
+ }
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();
475
967
  }
476
- }
477
-
478
- /**
479
- * Представляет синхронизацию доски с текущим узлом полного дерева решения (после `solved`).
480
- */
481
- function syncBoardFromSolutionTreeNode(): void {
482
- const tree = solutionPreviewTree;
483
- if (!tree || !solved) return;
484
- previewChess.setFen(tree.currentNode.data.fen);
485
- chessboard.fen = previewChess.fen();
486
- chessboard.addLastMove(tree.currentNode.data.lastMove ?? null);
487
- syncMoveEvaluationNagBadge(chessboard, tree.currentNode.data);
488
- chessboard.setNodeMarkers(tree.currentNode.data.parsedFirstComment?.shapes);
489
- syncBoardDragFromChessTurn();
490
- }
491
-
492
- /**
493
- * Представляет установку FEN движка при навигации по дереву (только после полного решения превью).
494
- */
495
- function treeSetChessFen(fen: string): void {
496
- if (!solved) return;
497
- previewChess.setFen(fen);
498
- }
499
-
500
- /**
501
- * Представляет обновление UI доски после смены узла в дереве решения.
502
- */
503
- function treeSetChessboardFen(_animationTime?: number): void {
504
- if (!solved) return;
505
- syncBoardFromSolutionTreeNode();
506
- }
507
-
508
- /**
509
- * Представляет реакцию на выбор узла в дереве (клик по ходу после решения).
510
- */
511
- function treeOnSelectNode(): void {
512
- if (!solved) return;
513
- syncBoardFromSolutionTreeNode();
514
- }
515
-
516
- /**
517
- * Представляет синхронизацию доски после операций с вариантами (в режиме просмотра без правки не используется).
518
- */
519
- function treeOnDeleteVariant(): void {
520
- if (!solved) return;
521
- syncBoardFromSolutionTreeNode();
522
- }
523
968
  </script>
524
969
 
525
- <div class={["flex flex-col gap-2", className]}>
526
- <PuzzleBoardTreeViewerPane
527
- {chessboard}
528
- chessTree={treeForViewer}
529
- onSelectNode={treeOnSelectNode}
530
- onDeleteVariant={treeOnDeleteVariant}
531
- setChessFen={treeSetChessFen}
532
- setChessboardFen={treeSetChessboardFen}
533
- editMode={false}
534
- selectable={solved}
535
- >
536
- {#snippet belowChessboard()}
537
- {#if wrongFeedback && wrongFeedback !== PREVIEW_WRONG_NO_VARIANT_MESSAGE}
538
- <p class="text-sm font-medium text-amber-600 dark:text-amber-400">
539
- {wrongFeedback}
540
- </p>
541
- {/if}
542
- {/snippet}
543
- </PuzzleBoardTreeViewerPane>
970
+ <div class={cn("flex w-full flex-col gap-2", className)}>
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>
544
991
  </div>