@connectorvol/chess-widgets 8.0.1 → 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 +74 -70
  6. package/dist/game-analyzer/gameAnalyzer.svelte.js +8 -6
  7. package/dist/game-analyzer/types.d.ts +11 -3
  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 -74
  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 +24 -11
  37. package/dist/puzzle-creation/StepPreview.svelte.d.ts +3 -1
  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 +14 -9
  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 +37 -7
  47. package/package.json +20 -17
@@ -23,29 +23,47 @@
23
23
 
24
24
  import { INITIAL_FEN } from "@connectorvol/chessops/fen";
25
25
  import { PgnOps } from "@connectorvol/chessops/pgnOps.svelte";
26
- 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";
27
34
 
28
35
  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
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,
32
45
  puzzlePreviewClassifySolverMove,
33
46
  puzzlePreviewDupSubtreeUnderStudentCursor,
34
47
  puzzlePreviewIsSolvedPosition,
35
48
  puzzlePreviewNodeAtPath,
49
+ puzzlePreviewRemapPathAfterReorder,
36
50
  puzzlePreviewSideToMoveFromFen,
37
51
  type TSolverMoveOutcome,
38
52
  } from "../puzzle/puzzleStepPreviewSolver.js";
39
53
  import PuzzleBoardTreeViewerPane from "./PuzzleBoardTreeViewerPane.svelte";
40
54
  import { cn } from "../utils.js";
41
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";
42
58
 
43
59
  let {
44
60
  pgn = "",
45
61
  fen: fenProp,
46
62
  onOutcome,
47
63
  boardTheme,
48
- boardAppearanceSettings,
64
+ boardAppearanceSettings = DEFAULT_BOARD_APPEARANCE_SETTINGS,
65
+ boardSize,
66
+ onResizeAction,
49
67
  class: className,
50
68
  wrongFeedback = $bindable<string | null>(null),
51
69
  solved = $bindable(false),
@@ -65,8 +83,28 @@
65
83
 
66
84
  let cursorPath = $state<number[]>([]);
67
85
 
86
+ const OPPONENT_MOVE_ANIMATION_MS = 300;
87
+
68
88
  let wrongRevealTimer: ReturnType<typeof setTimeout> | null = null;
69
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;
70
108
 
71
109
  let previewChess = new PgnOps(INITIAL_FEN, "chess");
72
110
 
@@ -78,6 +116,11 @@
78
116
  * Представляет синхронизацию доступности перетаскивания с очередью хода и состоянием «решено».
79
117
  */
80
118
  function syncBoardDragFromChessTurn(): void {
119
+ if (pendingWrongMoveReveal !== null) {
120
+ chessboard.draggable = Draggable.NONE;
121
+ return;
122
+ }
123
+
81
124
  const solver = puzzleSolverColor();
82
125
  chessboard.draggable = solved
83
126
  ? previewChess.turn() === Color.WHITE
@@ -90,6 +133,137 @@
90
133
  : Draggable.NONE;
91
134
  }
92
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
+
93
267
  /**
94
268
  * Представляет добавление пути в дерево ученика без очистки уже пройденных линий.
95
269
  */
@@ -100,22 +274,106 @@
100
274
  studentPreviewTree.currentNode = studentPreviewTree.rootNode.moves;
101
275
 
102
276
  let src = solution.rootNode.moves;
277
+ const solver = puzzleSolverColor();
278
+
279
+ applyPlayedSolverNagsAlongPath(solution, path, solver);
280
+
103
281
  for (const idx of path) {
104
282
  const next = src.children[idx];
105
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
+
106
294
  studentPreviewTree.addNodeToCurrent({
107
295
  id: "",
108
296
  children: [],
109
- data: { ...next.data },
297
+ data: {
298
+ ...next.data,
299
+ nags: studentNags,
300
+ },
110
301
  });
111
302
  src = next;
112
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;
113
368
  }
114
369
 
115
370
  /**
116
371
  * Представляет обработку ошибочного хода в превью.
117
372
  */
118
- function handleWrongOutcome(outcome: TSolverMoveOutcome): void {
373
+ function handleWrongOutcome(
374
+ outcome: TSolverMoveOutcome,
375
+ playedSan: string,
376
+ ): void {
119
377
  const solution = solutionPreviewTree;
120
378
  if (!solution) return;
121
379
 
@@ -127,16 +385,40 @@
127
385
  clearTimeout(advanceLineTimer);
128
386
  advanceLineTimer = null;
129
387
  }
388
+ if (opponentAutoTimer !== null) {
389
+ clearTimeout(opponentAutoTimer);
390
+ opponentAutoTimer = null;
391
+ }
130
392
 
131
393
  if (outcome.kind === "wrong_marked_variant") {
132
394
  wrongFeedback =
133
395
  "Неверно. Открываю введённый вариант в дереве, ход отменён.";
134
396
  onOutcome?.("failed");
135
397
  ensureStudentPreviewTreeHasPath(cursorPath);
398
+ const studentFork = studentPreviewTree.currentNode;
136
399
  puzzlePreviewDupSubtreeUnderStudentCursor(
137
400
  studentPreviewTree,
138
401
  outcome.wrongBranchRoot,
139
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");
140
422
  return;
141
423
  }
142
424
 
@@ -148,6 +430,8 @@
148
430
  * Представляет установку позиции и дерева ученика по пути из корня.
149
431
  */
150
432
  function setStateFromPath(path: number[]): void {
433
+ dismissPendingWrongMoveReveal();
434
+
151
435
  const solution = solutionPreviewTree;
152
436
  if (!solution) return;
153
437
 
@@ -161,30 +445,123 @@
161
445
  }
162
446
 
163
447
  cursorPath = path;
164
- ensureStudentPreviewTreeHasPath(cursorPath);
448
+ syncStudentTreeFromSolutionPath(path);
165
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
+ });
166
456
  syncBoardDragFromChessTurn();
167
457
  }
168
458
 
169
459
  /**
170
- * Представляет автоматический ход соперника по главному варианту после хода решателя.
460
+ * Представляет ответ соперника по главному варианту после хода решателя (без применения к движку).
171
461
  */
172
- function applyOpponentMainLineMove(pathAfterSolver: number[]): number[] {
462
+ function getOpponentMainLineReply(
463
+ pathAfterSolver: number[],
464
+ ): { san: string; path: number[] } | null {
173
465
  const solution = solutionPreviewTree;
174
- if (!solution) return pathAfterSolver;
466
+ if (!solution) return null;
175
467
 
176
468
  if (puzzlePreviewSideToMoveFromFen(previewChess.fen()) === puzzleSolverColor()) {
177
- return pathAfterSolver;
469
+ return null;
178
470
  }
179
471
 
180
472
  const node = puzzlePreviewNodeAtPath(
181
473
  solution.rootNode.moves,
182
474
  pathAfterSolver,
183
475
  );
184
- 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
+ }
185
558
 
186
- previewChess.makeSanMove(node.children[0]!.data.san);
187
- 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);
188
565
  }
189
566
 
190
567
  /**
@@ -258,6 +635,8 @@
258
635
  const solution = solutionPreviewTree;
259
636
  if (!solution || solved) return undefined;
260
637
 
638
+ cancelPendingWrongMoveReveal();
639
+
261
640
  const cursorNode = puzzlePreviewNodeAtPath(
262
641
  solution.rootNode.moves,
263
642
  cursorPath,
@@ -272,34 +651,34 @@
272
651
  );
273
652
 
274
653
  if (outcome.kind !== "correct") {
275
- handleWrongOutcome(outcome);
276
- 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
+ }
277
672
  }
278
673
 
279
674
  try {
280
- let path = [...cursorPath, outcome.childIndex];
675
+ const pathAfterSolver = [...cursorPath, outcome.childIndex];
281
676
  const moveRecord = previewChess.makeSanMove(san);
282
- path = applyOpponentMainLineMove(path);
283
- setStateFromPath(path);
284
-
285
- const leaf = puzzlePreviewNodeAtPath(solution.rootNode.moves, cursorPath);
286
- if (leaf && puzzlePreviewIsSolvedPosition(previewChess, leaf, puzzleSolverColor())) {
287
- if (advanceLineTimer !== null) {
288
- clearTimeout(advanceLineTimer);
289
- advanceLineTimer = null;
290
- }
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
- }
677
+ cursorPath = pathAfterSolver;
678
+ syncStudentTreeFromSolutionPath(pathAfterSolver);
301
679
 
302
680
  wrongFeedback = null;
681
+ lastSolverMoveForBoard = moveRecord;
303
682
  syncBoardDragFromChessTurn();
304
683
 
305
684
  return moveRecord;
@@ -347,8 +726,13 @@
347
726
  turn: previewChess.turn(),
348
727
  };
349
728
  },
350
- afterPieceMoveSan: () => {
351
- syncBoardDragFromChessTurn();
729
+ afterPieceMoveSan: (move) => {
730
+ lastSolverMoveForBoard = null;
731
+ if (solved) {
732
+ syncAnalysisBoardFromCurrentNode();
733
+ return;
734
+ }
735
+ afterSolverMoveOnBoard(move);
352
736
  },
353
737
  beforePieceMove: (from, to, promotion) => {
354
738
  if (solved) {
@@ -379,42 +763,46 @@
379
763
  return previewChess.fen();
380
764
  },
381
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
+ }
382
777
  syncBoardDragFromChessTurn();
383
778
  },
384
779
  },
385
780
  };
386
781
 
387
- let chessboard = $derived(
388
- createBoardApi({
782
+ let chessboard = $derived.by(() => {
783
+ const merged = buildPuzzleWizardBoardSettings(boardAppearanceSettings);
784
+ return createBoardApi({
389
785
  fen: INITIAL_FEN,
390
- settings: {
391
- ...boardAppearanceSettings,
392
- boardSize: 38,
393
- isResizable: false,
394
- orientation: Color.WHITE,
395
- draggable: Draggable.WHITE,
786
+ playSettings:
787
+ boardSize !== undefined
788
+ ? { ...merged.play, boardSize }
789
+ : merged.play,
790
+ actions: {
791
+ ...actions,
792
+ onResizeAction,
396
793
  },
397
- actions,
398
- theme: boardTheme ?? CHESSBOARD_THEMES.blue,
399
- }),
400
- );
794
+ });
795
+ });
796
+
797
+ const chessboardDesign = $derived({
798
+ ...buildPuzzleWizardBoardSettings(boardAppearanceSettings).design,
799
+ theme: boardTheme ?? CHESSBOARD_THEMES.blue,
800
+ });
401
801
 
402
802
  const treeForViewer = $derived(
403
803
  solved && solutionPreviewTree ? solutionPreviewTree : studentPreviewTree,
404
804
  );
405
805
 
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
806
  $effect(() => {
419
807
  const trimmedPgn = pgn.trim();
420
808
  const rootFen = fenProp?.trim() ? fenProp.trim() : INITIAL_FEN;
@@ -431,6 +819,10 @@
431
819
  rootFen.trim().split(/\s+/)[1] === "b"
432
820
  ? Color.BLACK
433
821
  : Color.WHITE;
822
+ syncMoveEvaluationNagBadge(chessboard, {
823
+ nags: undefined,
824
+ lastMove: null,
825
+ });
434
826
  cursorPath = [];
435
827
  solved = false;
436
828
  wrongFeedback = null;
@@ -444,17 +836,28 @@
444
836
  const { rootNode } = transformPgnToChessNode(fullPgn);
445
837
 
446
838
  layoutInitialFen = rootFen;
447
- 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;
448
845
 
449
846
  cursorPath = [];
450
847
  solved = false;
451
848
  wrongFeedback = null;
452
849
  previewChess.setFen(rootFen);
453
- studentPreviewTree.replaceRootTree(createEmptyTreeFromFen(rootFen));
850
+ studentPreviewTree.replaceRootTree(
851
+ createStudentPreviewTreeFromFen(rootFen, rootNode.comments),
852
+ );
454
853
  studentPreviewTree.currentNode = studentPreviewTree.rootNode.moves;
455
854
  chessboard.fen = rootFen;
456
855
  chessboard.orientation =
457
856
  rootFen.trim().split(/\s+/)[1] === "b" ? Color.BLACK : Color.WHITE;
857
+ syncMoveEvaluationNagBadge(chessboard, {
858
+ nags: undefined,
859
+ lastMove: null,
860
+ });
458
861
  syncBoardDragFromChessTurn();
459
862
  });
460
863
 
@@ -465,14 +868,15 @@
465
868
  * Представляет сброс отложенных таймеров превью.
466
869
  */
467
870
  function clearTimers(): void {
468
- if (wrongRevealTimer !== null) {
469
- clearTimeout(wrongRevealTimer);
470
- wrongRevealTimer = null;
471
- }
871
+ dismissPendingWrongMoveReveal();
472
872
  if (advanceLineTimer !== null) {
473
873
  clearTimeout(advanceLineTimer);
474
874
  advanceLineTimer = null;
475
875
  }
876
+ if (opponentAutoTimer !== null) {
877
+ clearTimeout(opponentAutoTimer);
878
+ opponentAutoTimer = null;
879
+ }
476
880
  }
477
881
 
478
882
  /**
@@ -481,11 +885,17 @@
481
885
  function syncBoardFromSolutionTreeNode(): void {
482
886
  const tree = solutionPreviewTree;
483
887
  if (!tree || !solved) return;
484
- previewChess.setFen(tree.currentNode.data.fen);
888
+
889
+ const node = tree.currentNode;
890
+
891
+ previewChess.setFen(node.data.fen);
485
892
  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);
893
+ chessboard.addLastMove(node.data.lastMove ?? null);
894
+ syncMoveEvaluationNagBadge(
895
+ chessboard,
896
+ nodeDataForAnalysisBoardBadge(node.data),
897
+ );
898
+ chessboard.setNodeMarkers(node.data.parsedFirstComment?.shapes);
489
899
  syncBoardDragFromChessTurn();
490
900
  }
491
901
 
@@ -500,7 +910,7 @@
500
910
  /**
501
911
  * Представляет обновление UI доски после смены узла в дереве решения.
502
912
  */
503
- function treeSetChessboardFen(_animationTime?: number): void {
913
+ function treeSetChessboardFen(_animationTime: number | undefined): void {
504
914
  if (!solved) return;
505
915
  syncBoardFromSolutionTreeNode();
506
916
  }
@@ -522,9 +932,10 @@
522
932
  }
523
933
  </script>
524
934
 
525
- <div class={["flex flex-col gap-2", className]}>
935
+ <div class={cn("flex w-full flex-col gap-2", className)}>
526
936
  <PuzzleBoardTreeViewerPane
527
937
  {chessboard}
938
+ {chessboardDesign}
528
939
  chessTree={treeForViewer}
529
940
  onSelectNode={treeOnSelectNode}
530
941
  onDeleteVariant={treeOnDeleteVariant}