@connectorvol/chess-widgets 8.0.0 → 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/constants/default-board-appearance-settings.d.ts +9 -0
  2. package/dist/constants/default-board-appearance-settings.js +30 -0
  3. package/dist/constants/editable-board-settings.d.ts +3 -21
  4. package/dist/constants/editable-board-settings.js +24 -19
  5. package/dist/game-analyzer/GameAnalyzer.svelte +38 -19
  6. package/dist/game-analyzer/gameAnalyzer.svelte.js +9 -7
  7. package/dist/game-analyzer/types.d.ts +10 -2
  8. package/dist/index.d.ts +9 -1
  9. package/dist/index.js +6 -0
  10. package/dist/position-editor/EditPanel.svelte +9 -6
  11. package/dist/puzzle/puzzleCreatedPayload.d.ts +17 -0
  12. package/dist/puzzle/puzzleData.d.ts +4 -0
  13. package/dist/puzzle/puzzleData.js +10 -0
  14. package/dist/puzzle/puzzlePreviewConstants.d.ts +4 -0
  15. package/dist/puzzle/puzzlePreviewConstants.js +4 -0
  16. package/dist/puzzle/puzzlePreviewPathNags.d.ts +6 -0
  17. package/dist/puzzle/puzzlePreviewPathNags.js +35 -0
  18. package/dist/puzzle/puzzleSolverForkAnnotations.d.ts +2 -4
  19. package/dist/puzzle/puzzleSolverForkAnnotations.js +13 -22
  20. package/dist/puzzle/puzzleStepPreviewSolver.d.ts +13 -1
  21. package/dist/puzzle/puzzleStepPreviewSolver.js +69 -9
  22. package/dist/puzzle/syncPuzzleBranchNags.d.ts +14 -0
  23. package/dist/puzzle/syncPuzzleBranchNags.js +125 -0
  24. package/dist/puzzle-creation/OpeningTagHoverPreview.svelte +81 -0
  25. package/dist/puzzle-creation/OpeningTagHoverPreview.svelte.d.ts +11 -0
  26. package/dist/puzzle-creation/PuzzleBoardTreeViewerPane.svelte +104 -32
  27. package/dist/puzzle-creation/PuzzleBoardTreeViewerPane.svelte.d.ts +16 -2
  28. package/dist/puzzle-creation/PuzzleCreationWizard.svelte +192 -202
  29. package/dist/puzzle-creation/PuzzleCreationWizard.svelte.d.ts +47 -3
  30. package/dist/puzzle-creation/PuzzlePgnBoardTreeEditor.svelte +485 -76
  31. package/dist/puzzle-creation/PuzzleWizardTagsStep.svelte +36 -0
  32. package/dist/puzzle-creation/PuzzleWizardTagsStep.svelte.d.ts +12 -0
  33. package/dist/puzzle-creation/StepMoves.svelte +38 -18
  34. package/dist/puzzle-creation/StepMoves.svelte.d.ts +2 -1
  35. package/dist/puzzle-creation/StepPosition.svelte +15 -9
  36. package/dist/puzzle-creation/StepPreview.svelte +23 -10
  37. package/dist/puzzle-creation/StepPreview.svelte.d.ts +2 -0
  38. package/dist/puzzle-creation/StepTags.svelte +270 -0
  39. package/dist/puzzle-creation/StepTags.svelte.d.ts +19 -0
  40. package/dist/puzzle-creation/buildPuzzleWizardBoardSettings.d.ts +9 -0
  41. package/dist/puzzle-creation/buildPuzzleWizardBoardSettings.js +19 -0
  42. package/dist/puzzle-creation/createPuzzleLineEditingBoard.d.ts +10 -3
  43. package/dist/puzzle-creation/createPuzzleLineEditingBoard.js +15 -11
  44. package/dist/puzzle-creation/puzzleWizardState.d.ts +35 -0
  45. package/dist/puzzle-creation/puzzleWizardState.js +37 -0
  46. package/dist/puzzle-creation/types.d.ts +35 -5
  47. package/package.json +20 -17
@@ -12,7 +12,6 @@
12
12
  import {
13
13
  CHESSBOARD_THEMES,
14
14
  createBoardApi,
15
- DEFAULT_BOARD_SETTINGS,
16
15
  Draggable,
17
16
  syncMoveEvaluationNagBadge,
18
17
  } from "@connectorvol/chessboard";
@@ -24,29 +23,47 @@
24
23
 
25
24
  import { INITIAL_FEN } from "@connectorvol/chessops/fen";
26
25
  import { PgnOps } from "@connectorvol/chessops/pgnOps.svelte";
27
- import { Color, calculatePly, type Move } from "@connectorvol/shared";
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";
28
34
 
29
35
  import { mergePgnWithSetupFen } from "../puzzle/mergePgnWithSetupFen.js";
30
- import { createEmptyTreeFromFen } from "../puzzle/puzzleData.js";
31
- import { PREVIEW_WRONG_NO_VARIANT_MESSAGE } from "../puzzle/puzzlePreviewConstants.js";
32
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,
33
45
  puzzlePreviewClassifySolverMove,
34
46
  puzzlePreviewDupSubtreeUnderStudentCursor,
35
47
  puzzlePreviewIsSolvedPosition,
36
48
  puzzlePreviewNodeAtPath,
49
+ puzzlePreviewRemapPathAfterReorder,
37
50
  puzzlePreviewSideToMoveFromFen,
38
51
  type TSolverMoveOutcome,
39
52
  } from "../puzzle/puzzleStepPreviewSolver.js";
40
53
  import PuzzleBoardTreeViewerPane from "./PuzzleBoardTreeViewerPane.svelte";
41
54
  import { cn } from "../utils.js";
42
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";
43
58
 
44
59
  let {
45
60
  pgn = "",
46
61
  fen: fenProp,
47
62
  onOutcome,
48
63
  boardTheme,
49
- boardAppearanceSettings,
64
+ boardAppearanceSettings = DEFAULT_BOARD_APPEARANCE_SETTINGS,
65
+ boardSize,
66
+ onResizeAction,
50
67
  class: className,
51
68
  wrongFeedback = $bindable<string | null>(null),
52
69
  solved = $bindable(false),
@@ -66,8 +83,28 @@
66
83
 
67
84
  let cursorPath = $state<number[]>([]);
68
85
 
86
+ const OPPONENT_MOVE_ANIMATION_MS = 300;
87
+
69
88
  let wrongRevealTimer: ReturnType<typeof setTimeout> | null = null;
70
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;
71
108
 
72
109
  let previewChess = new PgnOps(INITIAL_FEN, "chess");
73
110
 
@@ -79,6 +116,11 @@
79
116
  * Представляет синхронизацию доступности перетаскивания с очередью хода и состоянием «решено».
80
117
  */
81
118
  function syncBoardDragFromChessTurn(): void {
119
+ if (pendingWrongMoveReveal !== null) {
120
+ chessboard.draggable = Draggable.NONE;
121
+ return;
122
+ }
123
+
82
124
  const solver = puzzleSolverColor();
83
125
  chessboard.draggable = solved
84
126
  ? previewChess.turn() === Color.WHITE
@@ -91,6 +133,137 @@
91
133
  : Draggable.NONE;
92
134
  }
93
135
 
136
+ /**
137
+ * Представляет отмену отложенного отката ошибочного хода без записи варианта в дерево.
138
+ */
139
+ function dismissPendingWrongMoveReveal(): void {
140
+ if (wrongRevealTimer !== null) {
141
+ clearTimeout(wrongRevealTimer);
142
+ wrongRevealTimer = null;
143
+ }
144
+
145
+ const pending = pendingWrongMoveReveal;
146
+ if (pending === null) return;
147
+
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
+ }
169
+
170
+ finishWrongMoveReveal();
171
+ }
172
+
173
+ /**
174
+ * Представляет показ NAG ✗ на доске и отложенный откат ошибочного хода.
175
+ */
176
+ function scheduleWrongMoveReveal(wrongMove: Move): void {
177
+ const pending = pendingWrongMoveReveal;
178
+ if (pending === null) return;
179
+
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),
246
+ );
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;
258
+ }
259
+
260
+ syncCorrectSolverMoveNagBadge(move);
261
+ syncBoardDragFromChessTurn();
262
+ if (!solved) {
263
+ scheduleOpponentAutoResponse();
264
+ }
265
+ }
266
+
94
267
  /**
95
268
  * Представляет добавление пути в дерево ученика без очистки уже пройденных линий.
96
269
  */
@@ -101,22 +274,106 @@
101
274
  studentPreviewTree.currentNode = studentPreviewTree.rootNode.moves;
102
275
 
103
276
  let src = solution.rootNode.moves;
277
+ const solver = puzzleSolverColor();
278
+
279
+ applyPlayedSolverNagsAlongPath(solution, path, solver);
280
+
104
281
  for (const idx of path) {
105
282
  const next = src.children[idx];
106
283
  if (!next) break;
284
+
285
+ const studentNags = next.data.nags ? [...next.data.nags] : undefined;
286
+
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
+ }
293
+
107
294
  studentPreviewTree.addNodeToCurrent({
108
295
  id: "",
109
296
  children: [],
110
- data: { ...next.data },
297
+ data: {
298
+ ...next.data,
299
+ nags: studentNags,
300
+ },
111
301
  });
112
302
  src = next;
113
303
  }
304
+
305
+ const solverColor = puzzleSolverColor();
306
+ normalizePuzzleTreeMainLine(studentPreviewTree.rootNode.moves, solverColor);
307
+ }
308
+
309
+ /**
310
+ * Представляет нормализацию главной линии и NAG только в дереве решения (PGN).
311
+ */
312
+ function normalizeSolutionTree(): void {
313
+ const solution = solutionPreviewTree;
314
+ if (!solution) return;
315
+
316
+ const solverColor = puzzleSolverColor();
317
+ if (normalizePuzzleTreeMainLine(solution.rootNode.moves, solverColor)) {
318
+ solution.mutationVersion++;
319
+ }
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
+
350
+ normalizeSolutionTree();
351
+
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;
114
368
  }
115
369
 
116
370
  /**
117
371
  * Представляет обработку ошибочного хода в превью.
118
372
  */
119
- function handleWrongOutcome(outcome: TSolverMoveOutcome): void {
373
+ function handleWrongOutcome(
374
+ outcome: TSolverMoveOutcome,
375
+ playedSan: string,
376
+ ): void {
120
377
  const solution = solutionPreviewTree;
121
378
  if (!solution) return;
122
379
 
@@ -128,16 +385,40 @@
128
385
  clearTimeout(advanceLineTimer);
129
386
  advanceLineTimer = null;
130
387
  }
388
+ if (opponentAutoTimer !== null) {
389
+ clearTimeout(opponentAutoTimer);
390
+ opponentAutoTimer = null;
391
+ }
131
392
 
132
393
  if (outcome.kind === "wrong_marked_variant") {
133
394
  wrongFeedback =
134
395
  "Неверно. Открываю введённый вариант в дереве, ход отменён.";
135
396
  onOutcome?.("failed");
136
397
  ensureStudentPreviewTreeHasPath(cursorPath);
398
+ const studentFork = studentPreviewTree.currentNode;
137
399
  puzzlePreviewDupSubtreeUnderStudentCursor(
138
400
  studentPreviewTree,
139
401
  outcome.wrongBranchRoot,
140
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;
412
+ }
413
+
414
+ if (outcome.kind === "no_matching_variant") {
415
+ if (addAdHocWrongMoveToTrees(playedSan)) {
416
+ wrongFeedback =
417
+ "Неверно. Ход добавлен в дерево вариантов, позиция на доске отменена.";
418
+ } else {
419
+ wrongFeedback = PREVIEW_WRONG_NO_VARIANT_MESSAGE;
420
+ }
421
+ onOutcome?.("failed");
141
422
  return;
142
423
  }
143
424
 
@@ -149,6 +430,8 @@
149
430
  * Представляет установку позиции и дерева ученика по пути из корня.
150
431
  */
151
432
  function setStateFromPath(path: number[]): void {
433
+ dismissPendingWrongMoveReveal();
434
+
152
435
  const solution = solutionPreviewTree;
153
436
  if (!solution) return;
154
437
 
@@ -162,30 +445,123 @@
162
445
  }
163
446
 
164
447
  cursorPath = path;
165
- ensureStudentPreviewTreeHasPath(cursorPath);
448
+ syncStudentTreeFromSolutionPath(path);
166
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
+ });
167
456
  syncBoardDragFromChessTurn();
168
457
  }
169
458
 
170
459
  /**
171
- * Представляет автоматический ход соперника по главному варианту после хода решателя.
460
+ * Представляет ответ соперника по главному варианту после хода решателя (без применения к движку).
172
461
  */
173
- function applyOpponentMainLineMove(pathAfterSolver: number[]): number[] {
462
+ function getOpponentMainLineReply(
463
+ pathAfterSolver: number[],
464
+ ): { san: string; path: number[] } | null {
174
465
  const solution = solutionPreviewTree;
175
- if (!solution) return pathAfterSolver;
466
+ if (!solution) return null;
176
467
 
177
468
  if (puzzlePreviewSideToMoveFromFen(previewChess.fen()) === puzzleSolverColor()) {
178
- return pathAfterSolver;
469
+ return null;
179
470
  }
180
471
 
181
472
  const node = puzzlePreviewNodeAtPath(
182
473
  solution.rootNode.moves,
183
474
  pathAfterSolver,
184
475
  );
185
- if (!node || node.children.length === 0) return pathAfterSolver;
476
+ if (!node || node.children.length === 0) return null;
477
+
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(
504
+ solution.rootNode.moves,
505
+ cursorPath,
506
+ );
507
+ solution.currentNode = target ?? solution.rootNode.moves;
508
+ solved = true;
509
+ onOutcome?.("solved");
510
+ syncBoardFromSolutionTreeNode();
511
+ }
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
+ }
527
+
528
+ let opponentMove: Move;
529
+ try {
530
+ opponentMove = previewChess.makeSanMove(reply.san);
531
+ } catch {
532
+ return;
533
+ }
534
+
535
+ cursorPath = reply.path;
536
+ syncStudentTreeFromSolutionPath(cursorPath);
537
+
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;
557
+ }
186
558
 
187
- previewChess.makeSanMove(node.children[0]!.data.san);
188
- return [...pathAfterSolver, 0];
559
+ const pathAfterSolver = [...cursorPath];
560
+ const delayMs = chessboard.animationTime || OPPONENT_MOVE_ANIMATION_MS;
561
+ opponentAutoTimer = setTimeout(() => {
562
+ playOpponentAutoResponse(pathAfterSolver);
563
+ opponentAutoTimer = null;
564
+ }, delayMs);
189
565
  }
190
566
 
191
567
  /**
@@ -259,6 +635,8 @@
259
635
  const solution = solutionPreviewTree;
260
636
  if (!solution || solved) return undefined;
261
637
 
638
+ cancelPendingWrongMoveReveal();
639
+
262
640
  const cursorNode = puzzlePreviewNodeAtPath(
263
641
  solution.rootNode.moves,
264
642
  cursorPath,
@@ -273,34 +651,34 @@
273
651
  );
274
652
 
275
653
  if (outcome.kind !== "correct") {
276
- handleWrongOutcome(outcome);
277
- return undefined;
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
+ }
278
672
  }
279
673
 
280
674
  try {
281
- let path = [...cursorPath, outcome.childIndex];
675
+ const pathAfterSolver = [...cursorPath, outcome.childIndex];
282
676
  const moveRecord = previewChess.makeSanMove(san);
283
- path = applyOpponentMainLineMove(path);
284
- setStateFromPath(path);
285
-
286
- const leaf = puzzlePreviewNodeAtPath(solution.rootNode.moves, cursorPath);
287
- if (leaf && puzzlePreviewIsSolvedPosition(previewChess, leaf, puzzleSolverColor())) {
288
- if (advanceLineTimer !== null) {
289
- clearTimeout(advanceLineTimer);
290
- advanceLineTimer = null;
291
- }
292
- advanceLineTimer = setTimeout(() => {
293
- const advanced = maybeAdvanceToNextOpponentLine();
294
- if (!advanced) {
295
- solved = true;
296
- onOutcome?.("solved");
297
- }
298
- syncBoardDragFromChessTurn();
299
- advanceLineTimer = null;
300
- }, 250);
301
- }
677
+ cursorPath = pathAfterSolver;
678
+ syncStudentTreeFromSolutionPath(pathAfterSolver);
302
679
 
303
680
  wrongFeedback = null;
681
+ lastSolverMoveForBoard = moveRecord;
304
682
  syncBoardDragFromChessTurn();
305
683
 
306
684
  return moveRecord;
@@ -348,8 +726,13 @@
348
726
  turn: previewChess.turn(),
349
727
  };
350
728
  },
351
- afterPieceMoveSan: () => {
352
- syncBoardDragFromChessTurn();
729
+ afterPieceMoveSan: (move) => {
730
+ lastSolverMoveForBoard = null;
731
+ if (solved) {
732
+ syncAnalysisBoardFromCurrentNode();
733
+ return;
734
+ }
735
+ afterSolverMoveOnBoard(move);
353
736
  },
354
737
  beforePieceMove: (from, to, promotion) => {
355
738
  if (solved) {
@@ -380,43 +763,46 @@
380
763
  return previewChess.fen();
381
764
  },
382
765
  afterPieceMove: () => {
766
+ if (solved) {
767
+ lastSolverMoveForBoard = null;
768
+ syncAnalysisBoardFromCurrentNode();
769
+ return;
770
+ }
771
+ const move = lastSolverMoveForBoard;
772
+ lastSolverMoveForBoard = null;
773
+ if (move) {
774
+ afterSolverMoveOnBoard(move);
775
+ return;
776
+ }
383
777
  syncBoardDragFromChessTurn();
384
778
  },
385
779
  },
386
780
  };
387
781
 
388
- let chessboard = $derived(
389
- createBoardApi({
782
+ let chessboard = $derived.by(() => {
783
+ const merged = buildPuzzleWizardBoardSettings(boardAppearanceSettings);
784
+ return createBoardApi({
390
785
  fen: INITIAL_FEN,
391
- settings: {
392
- ...DEFAULT_BOARD_SETTINGS,
393
- ...(boardAppearanceSettings ?? {}),
394
- boardSize: 38,
395
- isResizable: false,
396
- orientation: Color.WHITE,
397
- draggable: Draggable.WHITE,
786
+ playSettings:
787
+ boardSize !== undefined
788
+ ? { ...merged.play, boardSize }
789
+ : merged.play,
790
+ actions: {
791
+ ...actions,
792
+ onResizeAction,
398
793
  },
399
- actions,
400
- theme: boardTheme ?? CHESSBOARD_THEMES.blue,
401
- }),
402
- );
794
+ });
795
+ });
796
+
797
+ const chessboardDesign = $derived({
798
+ ...buildPuzzleWizardBoardSettings(boardAppearanceSettings).design,
799
+ theme: boardTheme ?? CHESSBOARD_THEMES.blue,
800
+ });
403
801
 
404
802
  const treeForViewer = $derived(
405
803
  solved && solutionPreviewTree ? solutionPreviewTree : studentPreviewTree,
406
804
  );
407
805
 
408
- /**
409
- * Представляет синхронизацию выбранного узла в полном дереве решения с пройденной линией при переходе в режим просмотра (`solved`).
410
- * Без этого после switch с `studentPreviewTree` у `solutionPreviewTree` остаётся `currentNode` у корня и подсветка в TreeViewer неверна.
411
- */
412
- $effect(() => {
413
- if (!solved || !solutionPreviewTree) return;
414
- const tree = solutionPreviewTree;
415
- const path = cursorPath;
416
- const target = puzzlePreviewNodeAtPath(tree.rootNode.moves, path);
417
- tree.currentNode = target ?? tree.rootNode.moves;
418
- });
419
-
420
806
  $effect(() => {
421
807
  const trimmedPgn = pgn.trim();
422
808
  const rootFen = fenProp?.trim() ? fenProp.trim() : INITIAL_FEN;
@@ -433,6 +819,10 @@
433
819
  rootFen.trim().split(/\s+/)[1] === "b"
434
820
  ? Color.BLACK
435
821
  : Color.WHITE;
822
+ syncMoveEvaluationNagBadge(chessboard, {
823
+ nags: undefined,
824
+ lastMove: null,
825
+ });
436
826
  cursorPath = [];
437
827
  solved = false;
438
828
  wrongFeedback = null;
@@ -446,17 +836,28 @@
446
836
  const { rootNode } = transformPgnToChessNode(fullPgn);
447
837
 
448
838
  layoutInitialFen = rootFen;
449
- solutionPreviewTree = new ChessTree(rootNode);
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;
450
845
 
451
846
  cursorPath = [];
452
847
  solved = false;
453
848
  wrongFeedback = null;
454
849
  previewChess.setFen(rootFen);
455
- studentPreviewTree.replaceRootTree(createEmptyTreeFromFen(rootFen));
850
+ studentPreviewTree.replaceRootTree(
851
+ createStudentPreviewTreeFromFen(rootFen, rootNode.comments),
852
+ );
456
853
  studentPreviewTree.currentNode = studentPreviewTree.rootNode.moves;
457
854
  chessboard.fen = rootFen;
458
855
  chessboard.orientation =
459
856
  rootFen.trim().split(/\s+/)[1] === "b" ? Color.BLACK : Color.WHITE;
857
+ syncMoveEvaluationNagBadge(chessboard, {
858
+ nags: undefined,
859
+ lastMove: null,
860
+ });
460
861
  syncBoardDragFromChessTurn();
461
862
  });
462
863
 
@@ -467,14 +868,15 @@
467
868
  * Представляет сброс отложенных таймеров превью.
468
869
  */
469
870
  function clearTimers(): void {
470
- if (wrongRevealTimer !== null) {
471
- clearTimeout(wrongRevealTimer);
472
- wrongRevealTimer = null;
473
- }
871
+ dismissPendingWrongMoveReveal();
474
872
  if (advanceLineTimer !== null) {
475
873
  clearTimeout(advanceLineTimer);
476
874
  advanceLineTimer = null;
477
875
  }
876
+ if (opponentAutoTimer !== null) {
877
+ clearTimeout(opponentAutoTimer);
878
+ opponentAutoTimer = null;
879
+ }
478
880
  }
479
881
 
480
882
  /**
@@ -483,11 +885,17 @@
483
885
  function syncBoardFromSolutionTreeNode(): void {
484
886
  const tree = solutionPreviewTree;
485
887
  if (!tree || !solved) return;
486
- previewChess.setFen(tree.currentNode.data.fen);
888
+
889
+ const node = tree.currentNode;
890
+
891
+ previewChess.setFen(node.data.fen);
487
892
  chessboard.fen = previewChess.fen();
488
- chessboard.addLastMove(tree.currentNode.data.lastMove ?? null);
489
- syncMoveEvaluationNagBadge(chessboard, tree.currentNode.data);
490
- chessboard.setNodeMarkers(tree.currentNode.data.parsedFirstComment?.shapes);
893
+ chessboard.addLastMove(node.data.lastMove ?? null);
894
+ syncMoveEvaluationNagBadge(
895
+ chessboard,
896
+ nodeDataForAnalysisBoardBadge(node.data),
897
+ );
898
+ chessboard.setNodeMarkers(node.data.parsedFirstComment?.shapes);
491
899
  syncBoardDragFromChessTurn();
492
900
  }
493
901
 
@@ -502,7 +910,7 @@
502
910
  /**
503
911
  * Представляет обновление UI доски после смены узла в дереве решения.
504
912
  */
505
- function treeSetChessboardFen(_animationTime?: number): void {
913
+ function treeSetChessboardFen(_animationTime: number | undefined): void {
506
914
  if (!solved) return;
507
915
  syncBoardFromSolutionTreeNode();
508
916
  }
@@ -524,9 +932,10 @@
524
932
  }
525
933
  </script>
526
934
 
527
- <div class={["flex flex-col gap-2", className]}>
935
+ <div class={cn("flex w-full flex-col gap-2", className)}>
528
936
  <PuzzleBoardTreeViewerPane
529
937
  {chessboard}
938
+ {chessboardDesign}
530
939
  chessTree={treeForViewer}
531
940
  onSelectNode={treeOnSelectNode}
532
941
  onDeleteVariant={treeOnDeleteVariant}