File size: 66,069 Bytes
c03d895
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
/**
 * board.js: Chessground wrapper
 *
 * Wraps the vendor Chessground() instance and a chessops position, and owns:
 *   - A branching move tree (_nodes/_rootChildren) used by the PGN panel:
 *     playing a move after navigating back adds a variation instead of
 *     overwriting the continuation from that point. A plain
 *     undo/redo FEN stack (_history/_future) sits alongside it as a
 *     fallback for Back/Forward once navigation walks off the tree (e.g.
 *     past a board edit that started a fresh one).
 *   - Best-move arrows for DTZ/DTC/DTM/DTM50, drawn via chessground's
 *     native drawable.autoShapes.
 *   - Click-to-place editing: click a spare piece, then click a square to
 *     place it. Drag-from-tray is also supported (chessground's
 *     dragNewPiece()).
 *   - goToNode() - jump to any node in the current move tree (used by
 *     the PGN panel, including variation moves) without resetting the tree
 *     or re-triggering a probe for a position that's already been evaluated.
 *   - Board/piece theming: the piece image path is owned by theme.js
 *     (Theme.pieceThemeFn()). reconstruct() updates the piece
 *     CSS <link> and repopulates the spare-tray/promotion-dialog images;
 *     chessground is never destroyed and rebuilt for theming, since piece
 *     images are plain CSS.
 *   - The active rules variant (Standard / Chess960) and the FEN protocol
 *     boundary between them. chessops's own `Chess` class implements both
 *     rulesets' move generation/legality/SAN identically and has no
 *     variant concept of its own - castling legality, paths, and rook
 *     squares are computed the same way regardless of variant (see
 *     chessgroundDests() below for the one place chessops is told which
 *     variant is active, purely to choose which destinations chessground
 *     is offered). The application's Standard-vs-Chess960 FEN contract is
 *     stricter than chessops's generic one: chessops's FEN parser resolves
 *     a 'K'/'Q'/'k'/'q' castling letter to whichever rook sits on the
 *     correct side of the king wherever the king happens to be (needed for
 *     Chess960, where the king isn't always on the e-file) and also accepts
 *     Shredder-FEN/X-FEN rook-file letters unconditionally - neither of
 *     which is a legal Standard-chess castling encoding. _buildPosition()
 *     below enforces the application's Standard-mode castling contract
 *     (only 'K'/'Q'/'k'/'q', each requiring the king on its e1/e8 home
 *     square and the resolved rook on its exact a1/h1/a8/h8 home square)
 *     before trusting chessops's more permissive parse, so the frontend
 *     and the backend (see app.py's _validate_fen_parts and python-chess's
 *     own chess960=False castling validation) agree on whether a given FEN
 *     is valid for the active variant.
 *
 * Every position-changing action - setPosition(), clear(), playMove(),
 * goBack()/goForward(), board edits (off-board drop, board-setup drag,
 * click-to-place), setVariant() - records itself (in the tree or the
 * undo/redo stack, whichever applies) and invokes the onPositionChange
 * callback itself, so callers never need to remember to do either one
 * manually.
 *
 * The chessops position (_position) is the single source of truth for
 * position/legality - chessground is a dumb, controlled rendering surface.
 * Every position-changing function ends with a call to _syncChessground(),
 * which pushes _position's current FEN (plus turn colour, check state, and
 * last-move squares) into chessground in one shot. The core mutation
 * handler (_handleBoardMutation) is idempotent rather than tracking which
 * of chessground's several event hooks handled a given user action,
 * because chessground fires multiple, differently-scoped events for what
 * is logically one move. For an ordinary relocation, events.move fires
 * first, then events.change, then movable.events.after (see board.ts's
 * baseMove()/userMove()). events.change carries no orig/dest, so if it won
 * the idempotency race it would take the generic "board edit" branch and
 * leave movable.events.after's orig/dest with nothing left to act on.
 * events.move is therefore the primary handler: it fires before
 * events.change and, unlike movable.events.after, carries orig/dest (plus
 * capture info), so it wins the idempotency race with enough information
 * to classify the move correctly. events.change and movable.events.after
 * are wired as idempotent fallbacks, and events.change is the only hook
 * that fires for an off-board deletion (drag.ts's deleteOnDropOff branch
 * calls it directly, with no accompanying events.move/
 * movable.events.after).
 */

import { Chessground } from '../vendor/chessground.min.js';
import { Theme }       from './theme.js';
import * as Storage    from './storage.js';

import { Chess, Castles, castlingSide } from '../vendor/chessops/chess.js';
import { parseFen, makeFen }       from '../vendor/chessops/fen.js';
import { chessgroundDests }        from '../vendor/chessops/compat.js';
import { parseSan, makeSan }       from '../vendor/chessops/san.js';
import { parseSquare, makeSquare } from '../vendor/chessops/util.js';

// ── Variant ──────────────────────────────────────────────────────────────
// The application's rules-mode state. Distinct from chessops's own `rules`
// property (always 'chess' for both Standard and Chess960 - see the
// module doc comment above): this is application metadata that travels
// alongside the position, never derived from it.
const VARIANTS = Object.freeze({ STANDARD: 'standard', CHESS960: 'chess960' });

const Board = (() => {

    let cg     = null;   // Chessground instance
    let _position = null;   // chessops Chess position - see module doc comment
    let _variant  = VARIANTS.STANDARD;
    let _onPositionChange    = null;
    let _onMoveHistoryChange = null;

    let _pendingPromotion = null;

    // ── Lock mode ────────────────────────────────────────────────────────────
    // When true, the board only accepts legal moves for the side to move;
    // spare-piece placement and off-board deletion are disabled. Toggled via
    // setLocked(). Moves played through the ranked-moves table, auto-play, or
    // the PGN panel are unaffected, since those are already always legal.
    let _locked = false;

    // [orig, dest] of the most recently played/navigated-to move, or
    // undefined. Fed into chessground's `lastMove` config on every sync.
    // Tracked separately from the move tree because Back/Forward/goToNode
    // land on a node that already has its own from/to squares recorded;
    // this variable is what lets _syncChessground() highlight the right
    // squares for those navigations too, not just for a freshly played move.
    let _lastMoveKeys = undefined;

    // ── Undo / redo stacks ────────────────────────────────────────────────────
    // Plain chronological FEN log, used only as a Back/Forward fallback once
    // navigation walks off the current move tree entirely (e.g. past a board
    // edit - edits aren't part of the tree, see _resetMoveLine below). While
    // the current position is inside the tree, goBack()/goForward() use the
    // tree's parent/activeChild pointers instead (see below).
    let _history = [];
    let _future  = [];
    const _MAX_HISTORY = 100;

    function _pushHistory(fen) {
        _history.push(fen);
        if (_history.length > _MAX_HISTORY) _history.shift();
        _future = [];   // any new position discards the redo stack
    }

    // ── Move tree ──────────────────────────────────────────────────────────────
    // Only legal moves via playMove() (and the promotion/drag-move
    // equivalents) are recorded here. setPosition() / clear() / board edits /
    // setVariant() reset the tree.
    //
    // Each node is {id, san, fen, from, to, parentId, children, activeChild}.
    // `id` is also the node's index in _nodes. parentId === ROOT (-1) means
    // the node replies to the tree's start position (_startFen). `children`
    // lists every move tried from that node - children[0] renders as the
    // "main" continuation, children[1+] as parenthesised variations.
    // `activeChild` is whichever child Forward should follow from that node
    // (whichever branch was most recently played/clicked into) - not
    // necessarily children[0]. `from`/`to` are the move's origin/destination
    // squares exactly as played (for a Chess960 castle, the king's origin
    // and whichever destination square chessground/chessops reported,
    // never assumed to be a fixed c/g-file square), recorded alongside
    // san/fen so lastMove highlighting works when navigating the tree, not
    // just on a freshly-played move.
    //
    // The PGN panel and PGN export walk this tree recursively so alternate
    // moves are preserved as variations instead of overwriting history.
    const ROOT = -1;

    let _nodes           = [];   // [{id, san, fen, from, to, parentId, children:[], activeChild}]
    let _rootChildren    = [];   // ids of moves played directly from _startFen
    let _rootActiveChild = null;
    let _currentId        = ROOT; // id of the node currently shown, or ROOT for _startFen itself
    let _startFen         = null; // FEN the current tree began from (for PGN formatting)

    function _childrenOf(id) { return id === ROOT ? _rootChildren : _nodes[id].children; }
    function _activeChildOf(id) { return id === ROOT ? _rootActiveChild : _nodes[id].activeChild; }
    function _setActiveChild(id, childId) {
        if (id === ROOT) _rootActiveChild = childId;
        else _nodes[id].activeChild = childId;
    }

    function _resetMoveLine() {
        _nodes            = [];
        _rootChildren     = [];
        _rootActiveChild  = null;
        _currentId        = ROOT;
        _future           = [];
        _startFen         = _position ? currentFen() : null;
        if (_onMoveHistoryChange) _emitHistory();
    }

    function _emitHistory() {
        if (_onMoveHistoryChange) {
            _onMoveHistoryChange({
                nodes: _nodes, rootChildren: _rootChildren,
                rootActiveChild: _rootActiveChild, currentId: _currentId,
            }, _startFen);
        }
    }

    // Records a played move as a child of the current node. If the current
    // node already has a child with this exact SAN (the user replayed a move
    // already tried from this point), that existing node is reused instead
    // of creating a duplicate. Otherwise a new node is created and appended
    // to the end of the children list, so an already-explored continuation
    // stays the main line and the newly-played move is recorded as a
    // parenthesised variation.
    function _addMove(san, fen, from, to) {
        _future = [];   // starting a fresh line invalidates any pre-tree redo
        const kids = _childrenOf(_currentId);
        let childId = kids.find(id => _nodes[id].san === san);
        if (childId === undefined) {
            childId = _nodes.length;
            _nodes.push({ id: childId, san, fen, from, to, parentId: _currentId, children: [], activeChild: null });
            kids.push(childId);
        }
        // (else: reused an existing child - same SAN replayed from this node)
        _setActiveChild(_currentId, childId);
        _currentId = childId;
        return childId;
    }

    // ── Piece theme ───────────────────────────────────────────────────────────
    // Delegates to theme.js, which owns the active piece set. Kept as a local
    // wrapper (rather than calling Theme.pieceThemeFn() inline everywhere)
    // so callers below don't need to know theme.js exists. Resolved once on
    // first use rather than re-fetched from Theme on every call.
    let _pieceThemeFn = null;
    function _pieceTheme(piece) {
        if (!_pieceThemeFn) _pieceThemeFn = Theme.pieceThemeFn();
        return _pieceThemeFn(piece);
    }

    // ── chessops adapter ─────────────────────────────────────────────────────
    // Small translation layer between this file's FEN-string/algebraic-key
    // vocabulary and chessops's Setup/Position/numeric-square vocabulary.
    // Contains no chess rules of its own - every legality, castling, and
    // check/mate determination below is delegated to chessops.

    function currentFen() { return makeFen(_position.toSetup()); }
    function _turnChar(pos) { return pos.turn === 'white' ? 'w' : 'b'; }

    function _fenErrorMessage(err) {
        switch (err && err.message) {
            case 'ERR_CASTLING':
                return "Invalid castling availability: expected some combination of 'K', 'Q', 'k', 'q', rook-file letters 'a'-'h'/'A'-'H', or '-'.";
            case 'ERR_TURN':      return 'Invalid FEN: side to move must be "w" or "b".';
            case 'ERR_EP_SQUARE': return 'Invalid FEN: en passant target square is not valid.';
            case 'ERR_HALFMOVES': return 'Invalid FEN: halfmove clock is not valid.';
            case 'ERR_FULLMOVES': return 'Invalid FEN: fullmove number is not valid.';
            case 'ERR_BOARD':     return 'Invalid FEN: board part is not valid.';
            default:               return 'Invalid FEN: position violates chess rules.';
        }
    }
    function _positionErrorMessage(err) {
        switch (err && err.message) {
            case 'ERR_KINGS':            return 'Position must have exactly one king per side.';
            case 'ERR_OPPOSITE_CHECK':   return 'Invalid position: the side not to move is already in check.';
            case 'ERR_PAWNS_ON_BACKRANK': return 'Invalid position: a pawn is on the back rank.';
            case 'ERR_EMPTY':            return 'Position has no pieces.';
            default:                      return 'Invalid FEN: position violates chess rules.';
        }
    }

    // A castling-availability letter with no matching king/rook pair on the
    // board (e.g. "K" with no white king and rook on their home squares) is
    // syntactically valid but not a legal right - chessops's own parser
    // silently drops it rather than rejecting the FEN (see parseCastlingFen
    // in fen.js), so that has to be checked here by comparing what was
    // requested against what the constructed position actually granted.
    // This works the same way for Standard 'KQkq' and Chess960 file-letter
    // rights, since both are resolved by chessops before this comparison;
    // no Standard-only assumption is made about which squares are valid.
    function _missingCastlingRights(setup, pos) {
        const missing = [];
        for (const square of setup.castlingRights) {
            if (!pos.castles.castlingRights.has(square)) missing.push(makeSquare(square));
        }
        return missing;
    }

    // Standard chess's castling letters mean something fixed: 'K'/'Q' is
    // the White rook on h1/a1, with the White king on e1 - never "whichever
    // rook is right/left of the king" (chessops's own, Chess960-capable,
    // resolution rule; see the module doc comment). A right that chessops
    // resolves to any other square is a Chess960-only reading of the same
    // letter and must be rejected here, in Standard mode, even though
    // chessops itself parsed it without complaint. Chess960 mode has no
    // "home square" of its own - any resolved king/rook pair is
    // acceptable there, which _missingCastlingRights above already covers.
    const _HOME_SQUARES = {
        white: { king: parseSquare('e1'), rook: { a: parseSquare('a1'), h: parseSquare('h1') } },
        black: { king: parseSquare('e8'), rook: { a: parseSquare('a8'), h: parseSquare('h8') } },
    };
    function _nonOrthodoxCastlingRights(pos) {
        const offending = [];
        for (const color of ['white', 'black']) {
            const kingSquare = pos.board.kingOf(color);
            for (const side of ['a', 'h']) {
                const rookSquare = pos.castles.rook[color][side];
                if (rookSquare === undefined) continue;
                if (kingSquare !== _HOME_SQUARES[color].king || rookSquare !== _HOME_SQUARES[color].rook[side]) {
                    offending.push(makeSquare(rookSquare));
                }
            }
        }
        return offending;
    }

    // Standard mode accepts only 'K'/'Q'/'k'/'q' (each optional, in that
    // order) or '-'; Chess960 additionally accepts Shredder-FEN/X-FEN
    // rook-file letters. Mirrors app.py's _validate_fen_parts exactly:
    // same accepted grammar, same rejected input, so a FEN the frontend
    // accepts is never one the backend then rejects, or vice versa.
    const _CASTLING_SYNTAX_STANDARD = /^K?Q?k?q?$/;
    const _CASTLING_SYNTAX_CHESS960 = /^[A-Ha-hKQkq]{1,4}$/;
    function _castlingSyntaxError(variant, field) {
        if (field === '-') return null;
        const pattern = variant === VARIANTS.CHESS960 ? _CASTLING_SYNTAX_CHESS960 : _CASTLING_SYNTAX_STANDARD;
        if (pattern.test(field)) return null;
        const expected = variant === VARIANTS.CHESS960
            ? "some combination of 'K', 'Q', 'k', 'q', rook-file letters 'a'-'h'/'A'-'H', or '-'"
            : "some combination of 'K', 'Q', 'k', 'q', or '-'";
        return `Invalid castling availability: '${field}' (expected ${expected}).`;
    }

    // Parses and fully validates a FEN into a chessops position under the
    // given variant (defaulting to the currently-active one), or returns a
    // user-facing reason it was rejected. Rejects a syntactically-valid
    // chessops parse that isn't actually a legal encoding for the
    // requested variant (see the two checks above and the module doc
    // comment) - the same FEN text can be accepted under one variant and
    // rejected under the other.
    function _buildPosition(fen, variant = _variant) {
        const fields = fen.split(' ');
        if (fields.length >= 3) {
            const syntaxError = _castlingSyntaxError(variant, fields[2]);
            if (syntaxError) return { ok: false, reason: syntaxError };
        }
        const parsed = parseFen(fen);
        if (parsed.isErr) return { ok: false, reason: _fenErrorMessage(parsed.error) };
        const setup = parsed.value;
        const built = Chess.fromSetup(setup);
        if (built.isErr) return { ok: false, reason: _positionErrorMessage(built.error) };
        const pos = built.value;
        const missing = _missingCastlingRights(setup, pos);
        if (missing.length) {
            return { ok: false, reason: `Invalid castling availability: no matching king and rook for ${missing.join(', ')}.` };
        }
        if (variant !== VARIANTS.CHESS960) {
            const nonOrthodox = _nonOrthodoxCastlingRights(pos);
            if (nonOrthodox.length) {
                return {
                    ok: false,
                    reason: `Invalid castling availability: Standard chess requires the king and rook on their home squares (${nonOrthodox.join(', ')} is not one).`,
                };
            }
        }
        return { ok: true, pos };
    }

    function _pieceCodeOf(piece) {
        if (!piece) return null;
        const letter = piece.role === 'knight' ? 'N' : piece.role[0].toUpperCase();
        return (piece.color === 'white' ? 'w' : 'b') + letter;
    }

    // Total legal move count, counting each underpromotion choice as a
    // separate move (matching the count a full legal-move generator would
    // report) rather than one destination square per promoting pawn move.
    // Includes Chess960 castling moves - they're just another destination
    // chessops's own move generation supplies (see chessgroundDests below).
    function _countLegalMoves(pos) {
        const ctx = pos.ctx();
        let count = 0;
        for (const [from, squares] of pos.allDests(ctx)) {
            const promotes = pos.board.pawn.has(from);
            for (const to of squares) {
                const isPromotion = promotes && (to < 8 || to >= 56);
                count += isPromotion ? 4 : 1;
            }
        }
        return count;
    }

    // Builds chessground's `movable.dests` shape from the active position,
    // using the variant-aware compatibility bridge. Standard mode exposes
    // orthodox king-destination castling only; Chess960 exposes the
    // king-to-rook destination.
    function _legalDestsMap() {
        if (!_position) return new Map();
        const dests = chessgroundDests(_position, { chess960: _variant === VARIANTS.CHESS960 });
        if (_variant === VARIANTS.CHESS960) return dests;

        const filtered = new Map();
        for (const [from, squares] of dests) {
            const fromSquare = parseSquare(from);
            const allowed = squares.filter(to => {
                const toSquare = parseSquare(to);
                const side = castlingSide(_position, { from: fromSquare, to: toSquare });
                if (!side) return true;
                return toSquare !== _position.castles.rook[_position.turn][side];
            });
            if (allowed.length) filtered.set(from, allowed);
        }
        return filtered;
    }

    // ── Chessground sync ──────────────────────────────────────────────────────
    // The single point where _position's state is pushed into chessground.
    // Called unconditionally after every position-changing action - even a
    // "legal move" one, since chessground's own baseMove() has no concept of
    // en passant, promotion, or Chess960 castling and can leave its internal
    // state briefly chess-wise-incorrect (e.g. not removing the captured
    // pawn on an en passant capture, leaving a pawn on the back rank instead
    // of the promoted piece, or leaving the rook wherever it was dragged
    // from instead of its final castled square) until this call corrects
    // it. Chessground's internal state is not authoritative;
    // this call always overwrites it from _position.
    function _syncChessground() {
        if (!cg || !_position) return;
        cg.set({
            fen:       currentFen(),
            turnColor: _position.turn,
            check:     _position.isCheck() ? _position.turn : false,
            lastMove:  _lastMoveKeys,
            // Locked: restrict dragging/click-move to the side to move's
            // legal destinations. Unlocked: free editing.
            movable: {
                free:  !_locked,
                color: _locked ? _position.turn : 'both',
                dests: _locked ? _legalDestsMap() : undefined,
            },
            // Off-board drop-to-delete is disabled while locked, same as the
            // spare trays below.
            draggable: { deleteOnDropOff: !_locked },
        });
    }

    // ── Promotion ─────────────────────────────────────────────────────────────
    // pieceCode is a two-letter code like 'wP' (colour + uppercase type),
    // matching the shape the rest of this file (and theme.js) already uses.
    function _isPromotionMove(target, pieceCode) {
        if (!pieceCode || pieceCode[1] !== 'P') return false;
        const color = pieceCode[0], rank = target[1];
        return (color === 'w' && rank === '8') || (color === 'b' && rank === '1');
    }
    // Maps the promotion dialog's single-letter piece codes to chessops's
    // full role names.
    const ROLE_OF = { P: 'pawn', N: 'knight', B: 'bishop', R: 'rook', Q: 'queen', K: 'king' };

    function _showPromotionDialog(source, target, color) {
        _pendingPromotion = { source, target };
        const dialog = document.getElementById('promotion-dialog');
        ['q','r','b','n'].forEach(p => {
            const btn = dialog.querySelector(`.promotion-piece-btn[data-piece="${p}"]`);
            const img = btn && btn.querySelector('img');
            if (img) img.src = _pieceTheme(color + p.toUpperCase());
        });
        dialog.classList.add('is-visible');
        dialog.querySelector('.promotion-piece-btn')?.focus();
    }
    function _hidePromotionDialog() {
        document.getElementById('promotion-dialog').classList.remove('is-visible');
    }
    // Cancelling (Escape / backdrop click) needs to resync, since
    // chessground has already visually relocated the pawn to the target
    // square by the time the dialog is showing (its own drag/drop already
    // moved the piece before this handler ever runs). This call reverts
    // that visual relocation by pushing _position's still-unchanged state
    // back into chessground.
    function _cancelPromotion() {
        _hidePromotionDialog();
        _pendingPromotion = null;
        _syncChessground();
    }
    function _onPromotionSelect(pieceLetter) {
        _hidePromotionDialog();
        if (!_pendingPromotion) return;
        const { source, target } = _pendingPromotion;
        _pendingPromotion = null;
        const move = { from: parseSquare(source), to: parseSquare(target), promotion: ROLE_OF[pieceLetter.toUpperCase()] };
        if (!_position.isLegal(move)) { _syncChessground(); return; }   // revert the visual pawn-on-back-rank relocation
        const san = makeSan(_position, move);
        _position.play(move);
        _lastMoveKeys = [source, target];
        _addMove(san, currentFen(), source, target);
        _emitHistory();
        _syncChessground();
        // immediate=true: a completed promotion is a single, definite move
        // (see playMove()'s own comment on this parameter), not part of a
        // rapid-fire stream of changes the debounce below it is meant to
        // guard against.
        if (_onPositionChange) _onPositionChange(currentFen(), true);
    }

    // ── Core sync algorithm ──────────────────────────────────────────────────
    // Chessground fires multiple, differently-scoped callbacks for what a
    // user experiences as a single action (events.move, events.change,
    // movable.events.after, events.dropNewPiece), so rather than track
    // which-callback-handled-what with suppression flags, this handler is
    // idempotent: it compares chessground's current placement against
    // _position's, and no-ops if they already match.
    //
    // events.move is wired as the informative hook (the one that passes
    // origHint/destHint below) rather than movable.events.after, because of
    // chessground's event-firing order for an ordinary relocation:
    // events.move fires first (scheduled from within board.ts's baseMove()),
    // then events.change (also scheduled by baseMove(), immediately after),
    // then movable.events.after (scheduled later by userMove()) - all via
    // setTimeout(fn,1), so same-delay-timer FIFO ordering makes this
    // deterministic. events.change carries no orig/dest, so if it were the
    // informative hook it would always take the generic "board edit" branch
    // below first, leaving movable.events.after's real orig/dest with
    // nothing left to act on once it fires - the idempotency guard would
    // already see the position as synced. events.move fires before
    // events.change and, unlike movable.events.after, carries orig/dest
    // (plus capture info), so it reliably wins the idempotency race with
    // enough information to classify the move correctly.
    // events.change and movable.events.after are wired below as idempotent
    // fallbacks/no-ops for a relocation; events.change is load-bearing for
    // the one case with no orig/dest-carrying hook at all - an off-board
    // deletion (drag.ts's deleteOnDropOff branch).
    //
    // Standard mode accepts only the orthodox king-destination castle;
    // Chess960 accepts the king-to-rook representation.
    function _handleBoardMutation(origHint, destHint) {
        if (!cg || !_position) return;
        const cgPlacement  = cg.getFen();                 // chessground's own current placement-only FEN
        const ourPlacement = currentFen().split(' ')[0];
        if (cgPlacement === ourPlacement) return;          // already synced by another callback this tick

        if (origHint && destHint) {
            // _position hasn't moved yet at this point - only chessground
            // has - so the piece that was at origHint is still readable
            // from _position.
            const pieceCode = _pieceCodeOf(_position.board.get(parseSquare(origHint)));
            if (pieceCode && _isPromotionMove(destHint, pieceCode)) {
                _showPromotionDialog(origHint, destHint, pieceCode[0]);
                return;   // resolved later by _onPromotionSelect()/_cancelPromotion()
            }
            const move = { from: parseSquare(origHint), to: parseSquare(destHint) };
            if (_variant === VARIANTS.STANDARD) {
                const side = castlingSide(_position, move);
                if (side && move.to === _position.castles.rook[_position.turn][side]) {
                    _syncChessground();
                    return;
                }
            }
            if (_position.isLegal(move)) {
                const san = makeSan(_position, move);
                _position.play(move);
                _lastMoveKeys = [origHint, destHint];
                _addMove(san, currentFen(), origHint, destHint);
                _emitHistory();
                _syncChessground();
                // immediate=true: see the comment on the equivalent call in
                // _onPromotionSelect() above - a completed drag-and-drop
                // move is a single, definite move too.
                if (_onPositionChange) _onPositionChange(currentFen(), true);
                return;
            }
            // Not legal - fall through to the generic edit path below, which
            // reads the placement chessground already committed rather than
            // origHint/destHint.
        }

        // Board-edit path: spare-piece drop, off-board delete, or an
        // illegal relocation. Castling rights and the en passant square are
        // always dropped here because an edited placement's rights can no
        // longer be assumed valid.
        const prevFen = currentFen();
        const nextFen = `${cgPlacement} ${_turnChar(_position)} - - 0 1`;
        const built = _buildPosition(nextFen);
        if (!built.ok) { _syncChessground(); return; }   // invalid resulting position - snap back
        _position = built.pos;
        _pushHistory(prevFen);
        _lastMoveKeys = undefined;
        _resetMoveLine();
        _syncChessground();
        if (_onPositionChange) _onPositionChange(currentFen());
    }

    function _onCgSelect(key) {
        if (!_clickPlacePiece) return;         // not in placement mode - let chessground handle the click normally
        cg.selectSquare(null);                  // clear any selection chessground itself may have started,
                                                 // so a piece "armed" for placement never gets relocated
                                                 // by chessground's own click-to-move instead
        const role  = ROLE_OF[_clickPlacePiece[1]];
        const color = _clickPlacePiece[0] === 'w' ? 'white' : 'black';
        const square = parseSquare(key);
        if (role === 'king') {
            const existing = _position.board.kingOf(color);
            if (existing !== undefined && existing !== square) return;   // rejected - a king of this colour already exists elsewhere
        }
        const prevFen = currentFen();
        // Trial-mutate a clone rather than _position directly, so an
        // illegal result (e.g. two kings adjacent, or leaving the side not
        // to move in check) can be refused before anything commits - the
        // same validate-then-commit gate _handleBoardMutation's edit path
        // below already runs every other board edit through.
        const trial = _position.clone();
        trial.board.set(square, { role, color, promoted: false });
        // Re-derive castling rights from the edited board rather than
        // leaving stale rook/king square references behind (e.g. a right
        // pointing at a square this edit just overwrote).
        trial.castles = Castles.fromSetup({ board: trial.board, castlingRights: _position.castles.castlingRights });
        const nextFen = makeFen(trial.toSetup());
        if (nextFen === prevFen) return;   // identity placement (e.g. re-placing the same piece already there) - nothing to record
        const built = _buildPosition(nextFen);
        if (!built.ok) { setClickPlacePiece(null); return; }   // illegal result - refuse, board stays as it was
        _position = built.pos;
        _pushHistory(prevFen);
        _lastMoveKeys = undefined;
        _resetMoveLine();
        _syncChessground();
        if (_onPositionChange) _onPositionChange(currentFen());
    }

    // ── Best-move arrows (DTZ / DTC / DTM / DTM50) ────────────────────────────
    // Native chessground feature (drawable.autoShapes) - square keys, not
    // pixel coordinates, so chessground handles orientation/resizing itself.
    // bestMoves: { dtz: entry|null, dtc: entry|null, dtm: entry|null,
    // dtm50: entry|null } - the top-ranked move for each metric (any of
    // which may be missing, e.g. for a terminal or not-yet-probed position).
    function drawArrows(bestMoves) {
        if (!cg) return;
        if (!bestMoves) { cg.setAutoShapes([]); return; }
        const shapes = [];
        ['dtz', 'dtc', 'dtm', 'dtm50'].forEach(metric => {
            const entry = bestMoves[metric];
            if (!entry) return;
            const move = parseSan(_position, entry.san);
            if (!move) return;
            const side = _variant === VARIANTS.STANDARD ? castlingSide(_position, move) : undefined;
            const dest = side
                ? `${side === 'a' ? 'c' : 'g'}${_position.turn === 'white' ? '1' : '8'}`
                : makeSquare(move.to);
            shapes.push({ orig: makeSquare(move.from), dest, brush: metric });
        });
        cg.setAutoShapes(shapes);
    }
    function clearArrows() {
        if (!cg) return;
        cg.setAutoShapes([]);
    }

    // ── Click-to-place + spare-piece trays ───────────────────────────────────
    let _clickPlacePiece = null;   // e.g. 'wQ', 'bK', or null

    function _toCgPiece(code /* e.g. 'wQ' */) {
        return { color: code[0] === 'w' ? 'white' : 'black', role: ROLE_OF[code[1]] };
    }

    function _populateTrayImages() {
        document.querySelectorAll('.spare-piece').forEach(btn => {
            const code = btn.dataset.piece;
            const img  = btn.querySelector('img');
            if (img && code) img.src = _pieceTheme(code);
        });
    }

    // Two interaction paths, both needed: click-to-place (click a tray
    // piece, then click a destination square) and drag-from-tray (native
    // HTML5 drag onto the board, handled via chessground's dragNewPiece()).
    // The spare-piece trays are expected to support both at once, so
    // neither wiring below can assume the other is absent.
    function _initSpareTrays() {
        document.querySelectorAll('.spare-piece').forEach(btn => {
            const code = btn.dataset.piece;

            // Path 1: click tray piece, then click a square - handled by
            // this click listener (arms _clickPlacePiece) plus _onCgSelect()
            // (does the actual placement), wired in init()'s Config.events.select.
            // Disabled while locked (tray is also dimmed - see setLocked()).
            btn.addEventListener('click', () => {
                if (_locked) return;
                setClickPlacePiece(_clickPlacePiece === code ? null : code);
            });

            // Path 2: drag a tray piece directly onto the board. `force:
            // true` is required - cg.dragNewPiece() without it refuses to
            // overwrite an occupied square, and dropping a spare piece onto
            // an occupied square is expected to replace it. Disabled while locked.
            btn.addEventListener('pointerdown', e => {
                if (_locked) return;
                if (e.button !== undefined && e.button !== 0) return;   // primary pointer only
                cg.dragNewPiece(_toCgPiece(code), e, /* force */ true);
            });
        });

        // Escape clears click-to-place selection
        document.addEventListener('keydown', e => {
            if (e.key === 'Escape' && _clickPlacePiece) setClickPlacePiece(null);
        });
    }

    function setClickPlacePiece(piece) {
        _clickPlacePiece = piece;
        // Visual feedback
        document.querySelectorAll('.spare-piece.cp-selected').forEach(el => el.classList.remove('cp-selected'));
        if (piece) {
            document.querySelectorAll(`.spare-piece[data-piece="${piece}"]`).forEach(el => el.classList.add('cp-selected'));
        }
        // Update cursor on board
        const boardEl = document.getElementById('board');
        if (boardEl) {
            boardEl.classList.toggle('cp-active', !!piece);
        }
    }

    // ── Public API ────────────────────────────────────────────────────────────

    function _restoredOrientation() {
        return Storage.read('chesstb_orientation') === 'black' ? 'black' : 'white';
    }

    function _restoredLocked() {
        return Storage.read('chesstb_locked') === 'true';
    }

    // variant, if given, must be VARIANTS.STANDARD or VARIANTS.CHESS960 and
    // takes precedence over any caller-independent default - callers that
    // already know the intended variant (a URL share-link, a restored saved
    // game) pass it explicitly so the position below is constructed under
    // the correct ruleset context from the very first render, per this
    // file's variant-before-position invariant. Omitted or invalid values
    // fall back to Standard.
    function init(onPositionChange, initialFen, variant) {
        _onPositionChange = onPositionChange;
        _variant = (variant === VARIANTS.CHESS960) ? VARIANTS.CHESS960 : VARIANTS.STANDARD;

        // Same validation setPosition() applies to every later position
        // change - an invalid initialFen (bad king count, unusable castling
        // rights, or anything else chessops rejects) falls back to the
        // default reset position, and the reason is returned below so the
        // caller can tell the user, instead of silently substituting a
        // different position with no feedback.
        const built = _buildPosition(initialFen);
        let loadError = null;
        if (built.ok) {
            _position = built.pos;
        } else {
            loadError = built.reason;
            _position = Chess.default();
        }
        _startFen = currentFen();

        // Built from _startFen (the validated/fallback position), not the
        // raw initialFen - otherwise an invalid initialFen leaves the
        // visible board showing the rejected placement while _position
        // already holds the fallback, a desync baked in right at startup.
        cg = Chessground(document.getElementById('board'), {
            fen:                  _startFen,
            orientation:          _restoredOrientation(),
            turnColor:            _position.turn,
            check:                _position.isCheck() ? _position.turn : false,
            lastMove:             undefined,
            coordinates:          true,
            // coordinatesOnSquares stays false: that mode prints a combined
            // "a8"-style label on all 64 squares, whereas this app wants the
            // classic look - a rank number down the left column and a file
            // letter along the bottom row only, both sitting inset at the
            // board's edges. Chessground's native coords.ranks/coords.files
            // strips (one rank-only label per row, one file-only label per
            // column) already produce that layout once repositioned via CSS
            // (see chess/board.css) to sit inside the board edge instead of outside
            // it, so the combined per-square mode isn't needed here.
            coordinatesOnSquares: false,
            // Castling is driven by the chessops position (_position), not
            // chessground's own king-two-squares heuristic, so chessground
            // should never second-guess or auto-correct a king move into a
            // castle itself - this also holds for Chess960's king-to-rook
            // representation, which chessground has no native concept of.
            autoCastle:           false,
            viewOnly:             false,
            disableContextMenu:   true,
            highlight:            { lastMove: true, check: true },
            animation:            { enabled: true, duration: 200 },
            movable: {
                // Any drag is provisionally accepted here and classified
                // afterward by _handleBoardMutation, rather than gating
                // drags up front with a separate edit-mode toggle.
                free:  true,
                color: 'both',
                showDests: true,
                events: { after: (orig, dest) => _handleBoardMutation(orig, dest) },
            },
            premovable:  { enabled: false },
            draggable: {
                enabled:         true,
                showGhost:       true,
                deleteOnDropOff: true,
            },
            selectable: { enabled: true },
            events: {
                move:         (orig, dest) => _handleBoardMutation(orig, dest),
                change:       () => _handleBoardMutation(),
                dropNewPiece: () => _handleBoardMutation(),
                select:       (key) => _onCgSelect(key),
            },
            drawable: {
                enabled: true,
                visible: true,
                brushes: {
                    dtz:   { key: 'dtz',   color: '#2451c4', opacity: 0.80, lineWidth: 10 },
                    dtc:   { key: 'dtc',   color: '#d113c6', opacity: 0.80, lineWidth: 10 },
                    dtm:   { key: 'dtm',   color: '#8b93a1', opacity: 0.80, lineWidth: 10 },
                    dtm50: { key: 'dtm50', color: '#e1453a', opacity: 0.80, lineWidth: 10 },
                },
            },
        });

        const dialog = document.getElementById('promotion-dialog');
        dialog.querySelectorAll('.promotion-piece-btn').forEach(btn => {
            btn.addEventListener('click', () => _onPromotionSelect(btn.dataset.piece));
        });
        dialog.addEventListener('click', e => {
            if (e.target === dialog) _cancelPromotion();
        });
        document.addEventListener('keydown', e => {
            if (e.key !== 'Escape' || !dialog.classList.contains('is-visible')) return;
            _cancelPromotion();
            // The promotion dialog sits on top of everything else - stop
            // this Escape from also reaching the PGN dialog/settings-panel
            // handlers (see PgnDialog.jsx, SettingsPanel.jsx), which would otherwise close those too
            // if either happened to be open underneath it.
            e.stopImmediatePropagation();
        });

        _populateTrayImages();
        _initSpareTrays();
        _syncTrayOrientation();

        // Restored the same way orientation is above - persist:false since
        // this is a stored preference being applied on load, not a new one
        // being set, so it's not written straight back to the same key.
        setLocked(_restoredLocked(), false);

        return loadError ? { ok: false, reason: loadError } : { ok: true };
    }

    /**
     * Set the piece-set CSS <link> and populate the spare-tray and
     * promotion-dialog images. Registered with theme.js via
     * Theme.init(reconstruct) and invoked once at bootstrap, after theme.js
     * injects the board's theme <style> tag.
     *
     * This never touches the Chessground instance itself - piece images are
     * plain CSS (`.cg-wrap piece.<role>.<color>`), not baked into a
     * constructor closure.
     */
    function reconstruct() {
        if (!cg || !_position) return;
        const link = document.getElementById('chesstb-piece-css');
        if (link) link.href = `/static/css/pieces-${Theme.currentPieceSet()}.css`;
        _populateTrayImages();
    }

    function setPosition(fen) {
        const prevFen = _position ? currentFen() : null;
        // Re-submitting the FEN already on the board (e.g. pressing Enter in
        // the FEN box without editing it) is a no-op - skip the undo-stack
        // push and tree reset so the current move line survives it.
        if (fen === prevFen) return { ok: true };
        const built = _buildPosition(fen);
        if (!built.ok) return { ok: false, reason: built.reason };
        _position = built.pos;
        if (prevFen !== null) _pushHistory(prevFen);
        _hidePromotionDialog();
        _pendingPromotion = null;
        _lastMoveKeys = undefined;
        _resetMoveLine();
        _syncChessground();
        // Position-change callback, as in playMove()/goBack()/goForward()/
        // goToNode()/clear().
        const nextFen = currentFen();
        if (_onPositionChange) _onPositionChange(nextFen);
        return { ok: true };
    }

    // Sets a new FEN and variant together in a single step - for a caller
    // that is replacing the position anyway (a shared URL, PGN import,
    // restoreTree()'s fallback path), so there is no "current position"
    // to preserve or validate against the new variant; the new FEN is
    // validated directly against the new variant instead (see
    // _buildPosition). Use setVariant() instead when the FEN is meant to
    // stay exactly as it is and only its interpretation should change
    // (e.g. the settings-panel toggle).
    function setPositionAndVariant(fen, variant) {
        const next = (variant === VARIANTS.CHESS960) ? VARIANTS.CHESS960 : VARIANTS.STANDARD;
        const prevFen = _position ? currentFen() : null;
        if (fen === prevFen && next === _variant) return { ok: true };   // true no-op
        const built = _buildPosition(fen, next);
        if (!built.ok) return { ok: false, reason: built.reason };
        _variant = next;
        _position = built.pos;
        if (prevFen !== null) _pushHistory(prevFen);
        _hidePromotionDialog();
        _pendingPromotion = null;
        _lastMoveKeys = undefined;
        _resetMoveLine();
        _syncChessground();
        if (_onPositionChange) _onPositionChange(currentFen());
        return { ok: true };
    }

    function clear() {
        const prevFen  = currentFen();
        const resetFen = '4k3/8/8/8/8/8/8/4K3 w - - 0 1';
        const built = _buildPosition(resetFen);
        console.assert(built.ok, 'clear(): resetFen must always be valid');
        _pushHistory(prevFen);
        _position = built.pos;
        _hidePromotionDialog();
        _pendingPromotion = null;
        _lastMoveKeys = undefined;
        _resetMoveLine();
        _syncChessground();
        if (_onPositionChange) _onPositionChange(resetFen);
        return resetFen;
    }

    // Mirrors chessground's own orientation as a class on #board-wrap-inner;
    // chess/board.css uses it to flip flex-direction so the spare-piece trays swap
    // sides visually without moving them in the DOM.
    function _syncTrayOrientation() {
        const wrapEl = document.getElementById('board-wrap-inner');
        if (wrapEl) wrapEl.classList.toggle('orientation-black', cg.state.orientation === 'black');
    }

    function flip() {
        cg.toggleOrientation();
        _syncTrayOrientation();
        Storage.write('chesstb_orientation', cg.state.orientation);
        clearArrows();   // arrows are orientation-dependent; caller (useExplorer.js) redraws right after flip
    }

    // ── Lock mode (public) ───────────────────────────────────────────────────
    // Toggles legal-moves-only board interaction. Persisted to localStorage
    // the same way orientation is (see flip()) when persist defaults to
    // true; callers applying an already-known value (init() restoring on
    // load, or useExplorer.js's auto-play engaging/releasing Lock) pass persist:false.
    function setLocked(locked, persist = true) {
        _locked = !!locked;
        if (_locked) setClickPlacePiece(null);   // drop any armed spare piece
        document.querySelectorAll('.spare-tray').forEach(el => {
            el.classList.toggle('is-locked', _locked);
            if (_locked) el.setAttribute('title', 'Board is locked');
            else el.removeAttribute('title');
        });
        if (persist) Storage.write('chesstb_locked', _locked);
        _syncChessground();
    }
    function isLocked() { return _locked; }

    // ── Variant (public) ─────────────────────────────────────────────────────
    // Persistence and toggle-panel wiring live in useExplorer.js and
    // SettingsPanel.jsx (Chess960 Mode is a Settings-panel item, alongside
    // Autoplay Delay and Show Root Row) - this module only holds the
    // current value and reacts to it changing.
    function setVariant(variant) {
        const next = (variant === VARIANTS.CHESS960) ? VARIANTS.CHESS960 : VARIANTS.STANDARD;
        if (next === _variant) return { ok: true };
        // This reinterprets the current position under a different
        // variant - the FEN itself is not changing (see
        // setPositionAndVariant() above for the "also loading a new
        // position" case) - so unlike every other _buildPosition() call in
        // this file, failure here must not fall back to a default
        // position or otherwise change what's on the board: the position
        // simply isn't representable under the requested variant's
        // castling rules (e.g. Chess960-only castling rights present, or
        // Chess960 castling rights on non-home squares - see
        // _nonOrthodoxCastlingRights), and the switch is refused outright,
        // leaving variant and position exactly as they were.
        if (_position) {
            const trial = _buildPosition(currentFen(), next);
            if (!trial.ok) {
                const modeName = next === VARIANTS.CHESS960 ? 'Chess960' : 'Standard';
                return { ok: false, reason: `Can't switch to ${modeName} mode: ${trial.reason}` };
            }
        }
        _variant = next;
        // The move tree belongs to the active variant; changing variants
        // starts a fresh tree from the current position.
        _lastMoveKeys = undefined;
        _resetMoveLine();
        _syncChessground();
        // Position change with no actual FEN change - still routed through
        // onPositionChange so callers re-probe under the new variant and
        // (via onPositionChange's own guard) auto-play is stopped rather
        // than left running against a variant it started under.
        if (_onPositionChange) _onPositionChange(currentFen());
        return { ok: true };
    }
    function getVariant()  { return _variant; }
    function isChess960()  { return _variant === VARIANTS.CHESS960; }

    // Legal move count for the current position, counting each
    // underpromotion choice separately (see _countLegalMoves above) and
    // including Chess960 castling.
    function legalMoveCount() { return _position ? _countLegalMoves(_position) : 0; }
    function isInsufficientMaterial() { return !!_position && _position.isInsufficientMaterial(); }

    // Result token for an arbitrary FEN, independent of the live board
    // state - used by pgnFormat.js's PGN-result detection. Returns '*'
    // (unknown/in progress) for a FEN that fails to parse.
    function outcomeFromFen(fen) {
        const built = _buildPosition(fen);
        if (!built.ok) return '*';
        const pos = built.pos;
        if (pos.isCheckmate()) return pos.turn === 'white' ? '0-1' : '1-0';
        if (pos.isStalemate() || pos.isInsufficientMaterial()) return '1/2-1/2';
        return '*';
    }

    function playMove(san) {
        if (!_position) return false;
        const move = parseSan(_position, san);
        if (!move) return false;
        const fromKey = makeSquare(move.from), toKey = makeSquare(move.to);
        const playedSan = makeSan(_position, move);
        _position.play(move);

        _lastMoveKeys = [fromKey, toKey];

        // Track in the move tree (branches instead of overwriting if the
        // user had navigated back before playing this move).
        _addMove(playedSan, currentFen(), fromKey, toKey);
        _emitHistory();
        _syncChessground();

        // immediate=true: this is a single, definite move (played by
        // auto-play or by clicking a move in the ranked-moves table, the
        // two callers of playMove() - see useExplorer.js), not part of a
        // rapid-fire stream of changes the 300ms debounce guards against.
        if (_onPositionChange) _onPositionChange(currentFen(), true);
        return true;
    }

    // Back/Forward walk the move tree directly (parent / activeChild) so
    // stepping through a line that includes variations works the same as
    // stepping through the main line. The plain _history/_future FEN stack
    // is only consulted once navigation reaches the tree's root (id ROOT)
    // and goes further back than the tree itself - i.e. undoing past
    // whatever move/edit started the current tree (see _resetMoveLine).
    function goBack() {
        if (_currentId !== ROOT) {
            const parentId = _nodes[_currentId].parentId;
            const fen = parentId === ROOT ? _startFen : _nodes[parentId].fen;
            const built = _buildPosition(fen);
            if (!built.ok) { _syncChessground(); return false; }
            _position = built.pos;
            _lastMoveKeys = parentId === ROOT ? undefined : [_nodes[parentId].from, _nodes[parentId].to];
            _currentId = parentId;
        } else {
            if (_history.length === 0) return false;
            const prevFen = _history.pop();
            _future.push(currentFen());
            const built = _buildPosition(prevFen);
            if (!built.ok) {
                _future.pop();
                _history.push(prevFen);
                _syncChessground();
                return false;
            }
            _position = built.pos;
            // The plain _history/_future stack holds FENs only, with no
            // accompanying from/to squares (unlike tree nodes), so there's
            // nothing to highlight as the last move here.
            _lastMoveKeys = undefined;
            // The loaded position does not match the tree's _startFen, so
            // the tree is reset. _resetMoveLine() also clears _future, so the
            // stack is saved and restored around the reset.
            const _pendingFuture = _future.slice();
            _resetMoveLine();
            _future = _pendingFuture;
        }
        _hidePromotionDialog();
        _pendingPromotion = null;
        _syncChessground();
        _emitHistory();
        if (_onPositionChange) _onPositionChange(currentFen());
        return true;
    }

    function goForward() {
        const activeChildId = _activeChildOf(_currentId);
        if (activeChildId !== null && activeChildId !== undefined) {
            const node = _nodes[activeChildId];
            const built = _buildPosition(node.fen);
            if (!built.ok) { _syncChessground(); return false; }
            _position = built.pos;
            _lastMoveKeys = [node.from, node.to];
            _currentId = activeChildId;
        } else if (_currentId === ROOT && _future.length > 0) {
            const nextFen = _future.pop();
            const built = _buildPosition(nextFen);
            if (!built.ok) {
                _future.push(nextFen);
                _syncChessground();
                return false;
            }
            _history.push(currentFen());
            _position = built.pos;
            _lastMoveKeys = undefined;
            // Mirrors the goBack() fallback above: the position just loaded
            // isn't described by the current (already-reset) tree either, so
            // restart the tree here too and preserve any remaining redo
            // entries across the reset.
            const _pendingFuture = _future.slice();
            _resetMoveLine();
            _future = _pendingFuture;
        } else {
            return false;
        }
        _hidePromotionDialog();
        _pendingPromotion = null;
        _syncChessground();
        _emitHistory();
        if (_onPositionChange) _onPositionChange(currentFen());
        return true;
    }

    // goToNode() jumps to an arbitrary node in the current move tree (used
    // by the PGN panel - including variation moves, not just the main line).
    // Unlike setPosition(), this never calls _resetMoveLine(). targetId ===
    // ROOT (-1) means "start of the tree" (the position before any move was
    // played). Clicking any node marks it - and every ancestor on the path
    // to it - as its parent's activeChild, so Forward continues along
    // whichever branch was just brought into view.
    function goToNode(targetId) {
        if (targetId !== ROOT && !_nodes[targetId]) return false;
        if (targetId === _currentId) return true;

        // Walked before touching _position/_syncChessground, and bounded to
        // at most one hop per node, so a corrupt parentId chain (see
        // _isValidTree) refuses the jump instead of looping forever.
        const chain = [];
        let walk  = targetId;
        let guard = _nodes.length + 1;
        while (walk !== ROOT) {
            if (guard-- <= 0 || !_nodes[walk]) return false;
            chain.unshift(walk);
            walk = _nodes[walk].parentId;
        }

        const fen = targetId === ROOT ? _startFen : _nodes[targetId].fen;
        const built = _buildPosition(fen);
        if (!built.ok) return false;
        _position = built.pos;

        let parent = ROOT;
        chain.forEach(id => { _setActiveChild(parent, id); parent = id; });
        _currentId = targetId;
        _lastMoveKeys = targetId === ROOT ? undefined : [_nodes[targetId].from, _nodes[targetId].to];

        _hidePromotionDialog();
        _pendingPromotion = null;
        _syncChessground();
        _emitHistory();
        if (_onPositionChange) _onPositionChange(currentFen());
        return true;
    }

    // A legitimately-built tree (see _addMove above) only ever gives a node
    // a parentId that's ROOT or an already-existing, strictly smaller id,
    // and only ever lists children/activeChild with a strictly larger one,
    // so parentId chains and children references can never cycle or dangle.
    // Persisted state coming back out of localStorage bypasses that
    // construction path entirely; this re-checks the same invariant before
    // trusting it, since violating it would let goToNode()'s parentId walk
    // (or moveLine.js's tree walk) loop forever or index past the array.
    function _isValidNodeRef(id, nodesLength) {
        return typeof id === 'number' && Number.isInteger(id) && id >= 0 && id < nodesLength;
    }
    function _isValidTree(nodes, rootChildren) {
        if (!Array.isArray(nodes) || !Array.isArray(rootChildren)) return false;
        for (let id = 0; id < nodes.length; id++) {
            const node = nodes[id];
            if (!node || typeof node.san !== 'string' || typeof node.fen !== 'string') return false;
            if (node.parentId !== ROOT && !(_isValidNodeRef(node.parentId, nodes.length) && node.parentId < id)) {
                return false;
            }
            if (!Array.isArray(node.children)) return false;
            for (const childId of node.children) {
                if (!_isValidNodeRef(childId, nodes.length) || childId <= id) return false;
            }
            if (node.activeChild !== null && node.activeChild !== undefined &&
                !(_isValidNodeRef(node.activeChild, nodes.length) && node.activeChild > id)) {
                return false;
            }
        }
        return rootChildren.every(childId => _isValidNodeRef(childId, nodes.length));
    }

    // restoreTree() rehydrates a full move tree (start position + every
    // node + current node) from persisted state in a single board update,
    // without replaying each move or triggering individual probes.
    // The active variant must already be established (via init()'s variant
    // argument) before this is called - this function only restores
    // position/tree data, never variant.
    function restoreTree(startFen, nodes, rootChildren, rootActiveChild, currentId) {
        if (!_position || !startFen) return false;
        const validTree        = _isValidTree(nodes, rootChildren);
        const safeNodes        = validTree ? nodes : [];
        const safeRootChildren = validTree ? rootChildren : [];
        const safeCurrentId    = (currentId === ROOT || (validTree && _isValidNodeRef(currentId, safeNodes.length)))
            ? currentId : ROOT;

        const loadFen = safeCurrentId === ROOT ? startFen : safeNodes[safeCurrentId].fen;
        const built = _buildPosition(loadFen);
        if (!built.ok) {
            // Saved state doesn't parse (e.g. corrupted storage) - fall back
            // to just the start position with an empty tree.
            const fallback = _buildPosition(startFen);
            _position = fallback.ok ? fallback.pos : Chess.default();
            _startFen         = currentFen();
            _nodes            = [];
            _rootChildren     = [];
            _rootActiveChild  = null;
            _currentId        = ROOT;
            _history          = [];
            _future           = [];
            _lastMoveKeys     = undefined;
            if (cg) {
                _hidePromotionDialog();
                _syncChessground();
            }
            _emitHistory();
            return false;
        }
        _position = built.pos;

        _startFen        = startFen;
        _nodes            = safeNodes;
        _rootChildren     = safeRootChildren;
        _rootActiveChild  = _isValidNodeRef(rootActiveChild, safeNodes.length) ? rootActiveChild : null;
        _currentId        = safeCurrentId;
        _history          = [];
        _future           = [];
        _lastMoveKeys     = (safeCurrentId === ROOT || !safeNodes[safeCurrentId])
            ? undefined
            : [safeNodes[safeCurrentId].from, safeNodes[safeCurrentId].to];

        _hidePromotionDialog();
        _pendingPromotion = null;
        if (cg) _syncChessground();
        _emitHistory();
        return true;
    }

    // Install a parsed PGN in one update. Only the displayed start position
    // is probed; every imported move stays available for navigation/export.
    function importGame(tree, variant) {
        const next = variant === VARIANTS.CHESS960 ? VARIANTS.CHESS960 : VARIANTS.STANDARD;
        const built = _buildPosition(tree.startFen, next);
        if (!built.ok) return { ok: false, reason: built.reason };
        if (!_isValidTree(tree.nodes, tree.rootChildren)) {
            return { ok: false, reason: 'Invalid move tree in PGN.' };
        }
        _variant = next;
        _position = built.pos;
        restoreTree(tree.startFen, tree.nodes, tree.rootChildren, tree.rootActiveChild, ROOT);
        if (_onPositionChange) _onPositionChange(currentFen(), true);
        return { ok: true };
    }

    function canGoBack()    { return _currentId !== ROOT || _history.length > 0; }
    function canGoForward() {
        const activeChildId = _activeChildOf(_currentId);
        return (activeChildId !== null && activeChildId !== undefined) || (_currentId === ROOT && _future.length > 0);
    }

    function getMoveHistory() {
        return { nodes: _nodes, rootChildren: _rootChildren, rootActiveChild: _rootActiveChild, currentId: _currentId };
    }
    function getStartFen() { return _startFen; }       // PGN formatting
    function setOnMoveHistoryChange(fn) {
        _onMoveHistoryChange = fn;
    }

    return {
        init, reconstruct, setPosition, setPositionAndVariant, clear, flip, currentFen,
        playMove, goBack, goForward, goToNode, restoreTree, importGame, canGoBack, canGoForward,
        getMoveHistory, getStartFen, setOnMoveHistoryChange,
        drawArrows, clearArrows,
        setLocked, isLocked,
        setVariant, getVariant, isChess960, legalMoveCount, isInsufficientMaterial, outcomeFromFen,
    };

})();

export { Board, VARIANTS };