react-native-chess-kit 0.5.3 → 0.5.4

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 (91) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +187 -187
  3. package/lib/commonjs/board-annotations.js +8 -8
  4. package/lib/commonjs/board-annotations.js.map +1 -1
  5. package/lib/commonjs/board-arrows.js +7 -7
  6. package/lib/commonjs/board-arrows.js.map +1 -1
  7. package/lib/commonjs/board-background.js +5 -5
  8. package/lib/commonjs/board-background.js.map +1 -1
  9. package/lib/commonjs/board-coordinates.js.map +1 -1
  10. package/lib/commonjs/board-drag-ghost.js +10 -10
  11. package/lib/commonjs/board-drag-ghost.js.map +1 -1
  12. package/lib/commonjs/board-highlights.js +15 -15
  13. package/lib/commonjs/board-highlights.js.map +1 -1
  14. package/lib/commonjs/board-legal-dots.js +5 -5
  15. package/lib/commonjs/board-legal-dots.js.map +1 -1
  16. package/lib/commonjs/board-piece.js +25 -25
  17. package/lib/commonjs/board-piece.js.map +1 -1
  18. package/lib/commonjs/board-pieces.js +6 -6
  19. package/lib/commonjs/board-pieces.js.map +1 -1
  20. package/lib/commonjs/board.js +32 -28
  21. package/lib/commonjs/board.js.map +1 -1
  22. package/lib/commonjs/constants.js.map +1 -1
  23. package/lib/commonjs/index.js.map +1 -1
  24. package/lib/commonjs/pieces/default-pieces.js.map +1 -1
  25. package/lib/commonjs/pieces/index.js.map +1 -1
  26. package/lib/commonjs/promotion-picker.js.map +1 -1
  27. package/lib/commonjs/static-board.js.map +1 -1
  28. package/lib/commonjs/themes.js.map +1 -1
  29. package/lib/commonjs/types.js.map +1 -1
  30. package/lib/commonjs/use-board-gesture.js.map +1 -1
  31. package/lib/commonjs/use-board-pieces.js +15 -15
  32. package/lib/commonjs/use-board-pieces.js.map +1 -1
  33. package/lib/commonjs/use-board-state.js +8 -8
  34. package/lib/commonjs/use-board-state.js.map +1 -1
  35. package/lib/commonjs/use-premove.js +12 -12
  36. package/lib/commonjs/use-premove.js.map +1 -1
  37. package/lib/module/board-annotations.js +8 -8
  38. package/lib/module/board-annotations.js.map +1 -1
  39. package/lib/module/board-arrows.js +7 -7
  40. package/lib/module/board-arrows.js.map +1 -1
  41. package/lib/module/board-background.js +5 -5
  42. package/lib/module/board-background.js.map +1 -1
  43. package/lib/module/board-coordinates.js.map +1 -1
  44. package/lib/module/board-drag-ghost.js +10 -10
  45. package/lib/module/board-drag-ghost.js.map +1 -1
  46. package/lib/module/board-highlights.js +15 -15
  47. package/lib/module/board-highlights.js.map +1 -1
  48. package/lib/module/board-legal-dots.js +5 -5
  49. package/lib/module/board-legal-dots.js.map +1 -1
  50. package/lib/module/board-piece.js +25 -25
  51. package/lib/module/board-piece.js.map +1 -1
  52. package/lib/module/board-pieces.js +6 -6
  53. package/lib/module/board-pieces.js.map +1 -1
  54. package/lib/module/board.js +32 -28
  55. package/lib/module/board.js.map +1 -1
  56. package/lib/module/constants.js.map +1 -1
  57. package/lib/module/index.js.map +1 -1
  58. package/lib/module/pieces/default-pieces.js.map +1 -1
  59. package/lib/module/pieces/index.js.map +1 -1
  60. package/lib/module/promotion-picker.js.map +1 -1
  61. package/lib/module/static-board.js.map +1 -1
  62. package/lib/module/themes.js.map +1 -1
  63. package/lib/module/types.js.map +1 -1
  64. package/lib/module/use-board-gesture.js.map +1 -1
  65. package/lib/module/use-board-pieces.js +15 -15
  66. package/lib/module/use-board-pieces.js.map +1 -1
  67. package/lib/module/use-board-state.js +8 -8
  68. package/lib/module/use-board-state.js.map +1 -1
  69. package/lib/module/use-premove.js +12 -12
  70. package/lib/module/use-premove.js.map +1 -1
  71. package/lib/typescript/types.d.ts +2 -1
  72. package/lib/typescript/types.d.ts.map +1 -1
  73. package/package.json +95 -95
  74. package/src/board-annotations.tsx +147 -147
  75. package/src/board-arrows.tsx +197 -197
  76. package/src/board-background.tsx +46 -46
  77. package/src/board-drag-ghost.tsx +132 -132
  78. package/src/board-highlights.tsx +226 -226
  79. package/src/board-legal-dots.tsx +73 -73
  80. package/src/board-piece.tsx +160 -160
  81. package/src/board-pieces.tsx +63 -63
  82. package/src/board.tsx +688 -688
  83. package/src/constants.ts +103 -103
  84. package/src/index.ts +101 -101
  85. package/src/pieces/default-pieces.tsx +383 -383
  86. package/src/pieces/index.ts +1 -1
  87. package/src/themes.ts +129 -129
  88. package/src/types.ts +394 -394
  89. package/src/use-board-pieces.ts +158 -158
  90. package/src/use-board-state.ts +120 -120
  91. package/src/use-premove.ts +59 -59
@@ -1,158 +1,158 @@
1
- import { useMemo, useRef } from 'react';
2
-
3
- import type { ChessColor, BoardPiece, ParsedPiece } from './types';
4
-
5
- const FEN_PIECE_MAP: Record<string, { code: string; color: 'w' | 'b' }> = {
6
- p: { code: 'bp', color: 'b' },
7
- r: { code: 'br', color: 'b' },
8
- n: { code: 'bn', color: 'b' },
9
- b: { code: 'bb', color: 'b' },
10
- q: { code: 'bq', color: 'b' },
11
- k: { code: 'bk', color: 'b' },
12
- P: { code: 'wp', color: 'w' },
13
- R: { code: 'wr', color: 'w' },
14
- N: { code: 'wn', color: 'w' },
15
- B: { code: 'wb', color: 'w' },
16
- Q: { code: 'wq', color: 'w' },
17
- K: { code: 'wk', color: 'w' },
18
- };
19
-
20
- const FILES = 'abcdefgh';
21
-
22
- /**
23
- * Parse the piece-placement part of a FEN string into an array of pieces.
24
- * Pure function — no React dependencies, suitable for worklets if needed.
25
- */
26
- function parseFenPieces(fen: string): ParsedPiece[] {
27
- const placement = fen.split(' ')[0];
28
- const ranks = placement.split('/');
29
- const pieces: ParsedPiece[] = [];
30
-
31
- for (let rankIdx = 0; rankIdx < ranks.length; rankIdx++) {
32
- const rank = ranks[rankIdx];
33
- let fileIdx = 0;
34
-
35
- for (const char of rank) {
36
- if (char >= '1' && char <= '8') {
37
- fileIdx += parseInt(char, 10);
38
- continue;
39
- }
40
-
41
- const mapping = FEN_PIECE_MAP[char];
42
- if (mapping) {
43
- // FEN ranks are from rank 8 (index 0) down to rank 1 (index 7)
44
- const square = `${FILES[fileIdx]}${8 - rankIdx}`;
45
- pieces.push({ code: mapping.code, square, color: mapping.color });
46
- }
47
- fileIdx++;
48
- }
49
- }
50
-
51
- return pieces;
52
- }
53
-
54
- /**
55
- * Convert square notation to pixel coordinates (top-left corner).
56
- * Orientation-aware: flips the board when playing as black.
57
- */
58
- export function squareToXY(
59
- square: string,
60
- squareSize: number,
61
- orientation: ChessColor,
62
- ): { x: number; y: number } {
63
- 'worklet';
64
- const fileIdx = square.charCodeAt(0) - 97; // 'a'=0 .. 'h'=7
65
- const rankIdx = parseInt(square[1], 10) - 1; // '1'=0 .. '8'=7
66
-
67
- const col = orientation === 'white' ? fileIdx : 7 - fileIdx;
68
- const row = orientation === 'white' ? 7 - rankIdx : rankIdx;
69
-
70
- return { x: col * squareSize, y: row * squareSize };
71
- }
72
-
73
- /**
74
- * Convert pixel coordinates to a square notation string.
75
- * Clamps to board bounds. Orientation-aware.
76
- */
77
- export function xyToSquare(
78
- x: number,
79
- y: number,
80
- squareSize: number,
81
- orientation: ChessColor,
82
- ): string {
83
- 'worklet';
84
- const col = Math.max(0, Math.min(7, Math.floor(x / squareSize)));
85
- const row = Math.max(0, Math.min(7, Math.floor(y / squareSize)));
86
-
87
- const fileIdx = orientation === 'white' ? col : 7 - col;
88
- const rankIdx = orientation === 'white' ? 7 - row : row;
89
-
90
- // String.fromCharCode not available in worklets — use lookup
91
- const files = 'abcdefgh';
92
- return `${files[fileIdx]}${rankIdx + 1}`;
93
- }
94
-
95
- /**
96
- * Manages the piece list derived from FEN, with stable IDs for React keys.
97
- *
98
- * Stable IDs prevent unmount/remount cycles when pieces change position.
99
- * A piece keeps its ID as long as it exists on the board — only capture
100
- * (removal) or promotion (code change) creates a new ID.
101
- */
102
- export function useBoardPieces(fen: string): BoardPiece[] {
103
- // Track piece-code counters across renders for stable ID assignment
104
- const idCounterRef = useRef<Record<string, number>>({});
105
- const prevPiecesRef = useRef<BoardPiece[]>([]);
106
-
107
- return useMemo(() => {
108
- const parsed = parseFenPieces(fen);
109
- const prev = prevPiecesRef.current;
110
- const prevBySquare = new Map(prev.map((p) => [p.square, p]));
111
-
112
- // Try to reuse IDs from previous render:
113
- // 1. Same code on same square -> keep ID (piece didn't move)
114
- // 2. Same code moved to a new square -> find unmatched previous piece of same code
115
- const usedPrevIds = new Set<string>();
116
- const result: BoardPiece[] = [];
117
-
118
- // First pass: exact square matches (piece stayed or appeared on same square)
119
- const unmatched: ParsedPiece[] = [];
120
- for (const p of parsed) {
121
- const existing = prevBySquare.get(p.square);
122
- if (existing && existing.code === p.code && !usedPrevIds.has(existing.id)) {
123
- usedPrevIds.add(existing.id);
124
- result.push({ ...p, id: existing.id });
125
- } else {
126
- unmatched.push(p);
127
- }
128
- }
129
-
130
- // Second pass: match unmatched pieces by code to previous pieces that moved
131
- for (const p of unmatched) {
132
- let matchedId: string | null = null;
133
-
134
- for (const prevPiece of prev) {
135
- if (
136
- prevPiece.code === p.code &&
137
- !usedPrevIds.has(prevPiece.id)
138
- ) {
139
- matchedId = prevPiece.id;
140
- usedPrevIds.add(prevPiece.id);
141
- break;
142
- }
143
- }
144
-
145
- if (matchedId) {
146
- result.push({ ...p, id: matchedId });
147
- } else {
148
- // New piece (promotion, or first render) — assign fresh ID
149
- const counter = idCounterRef.current;
150
- counter[p.code] = (counter[p.code] ?? 0) + 1;
151
- result.push({ ...p, id: `${p.code}-${counter[p.code]}` });
152
- }
153
- }
154
-
155
- prevPiecesRef.current = result;
156
- return result;
157
- }, [fen]);
158
- }
1
+ import { useMemo, useRef } from 'react';
2
+
3
+ import type { ChessColor, BoardPiece, ParsedPiece } from './types';
4
+
5
+ const FEN_PIECE_MAP: Record<string, { code: string; color: 'w' | 'b' }> = {
6
+ p: { code: 'bp', color: 'b' },
7
+ r: { code: 'br', color: 'b' },
8
+ n: { code: 'bn', color: 'b' },
9
+ b: { code: 'bb', color: 'b' },
10
+ q: { code: 'bq', color: 'b' },
11
+ k: { code: 'bk', color: 'b' },
12
+ P: { code: 'wp', color: 'w' },
13
+ R: { code: 'wr', color: 'w' },
14
+ N: { code: 'wn', color: 'w' },
15
+ B: { code: 'wb', color: 'w' },
16
+ Q: { code: 'wq', color: 'w' },
17
+ K: { code: 'wk', color: 'w' },
18
+ };
19
+
20
+ const FILES = 'abcdefgh';
21
+
22
+ /**
23
+ * Parse the piece-placement part of a FEN string into an array of pieces.
24
+ * Pure function — no React dependencies, suitable for worklets if needed.
25
+ */
26
+ function parseFenPieces(fen: string): ParsedPiece[] {
27
+ const placement = fen.split(' ')[0];
28
+ const ranks = placement.split('/');
29
+ const pieces: ParsedPiece[] = [];
30
+
31
+ for (let rankIdx = 0; rankIdx < ranks.length; rankIdx++) {
32
+ const rank = ranks[rankIdx];
33
+ let fileIdx = 0;
34
+
35
+ for (const char of rank) {
36
+ if (char >= '1' && char <= '8') {
37
+ fileIdx += parseInt(char, 10);
38
+ continue;
39
+ }
40
+
41
+ const mapping = FEN_PIECE_MAP[char];
42
+ if (mapping) {
43
+ // FEN ranks are from rank 8 (index 0) down to rank 1 (index 7)
44
+ const square = `${FILES[fileIdx]}${8 - rankIdx}`;
45
+ pieces.push({ code: mapping.code, square, color: mapping.color });
46
+ }
47
+ fileIdx++;
48
+ }
49
+ }
50
+
51
+ return pieces;
52
+ }
53
+
54
+ /**
55
+ * Convert square notation to pixel coordinates (top-left corner).
56
+ * Orientation-aware: flips the board when playing as black.
57
+ */
58
+ export function squareToXY(
59
+ square: string,
60
+ squareSize: number,
61
+ orientation: ChessColor,
62
+ ): { x: number; y: number } {
63
+ 'worklet';
64
+ const fileIdx = square.charCodeAt(0) - 97; // 'a'=0 .. 'h'=7
65
+ const rankIdx = parseInt(square[1], 10) - 1; // '1'=0 .. '8'=7
66
+
67
+ const col = orientation === 'white' ? fileIdx : 7 - fileIdx;
68
+ const row = orientation === 'white' ? 7 - rankIdx : rankIdx;
69
+
70
+ return { x: col * squareSize, y: row * squareSize };
71
+ }
72
+
73
+ /**
74
+ * Convert pixel coordinates to a square notation string.
75
+ * Clamps to board bounds. Orientation-aware.
76
+ */
77
+ export function xyToSquare(
78
+ x: number,
79
+ y: number,
80
+ squareSize: number,
81
+ orientation: ChessColor,
82
+ ): string {
83
+ 'worklet';
84
+ const col = Math.max(0, Math.min(7, Math.floor(x / squareSize)));
85
+ const row = Math.max(0, Math.min(7, Math.floor(y / squareSize)));
86
+
87
+ const fileIdx = orientation === 'white' ? col : 7 - col;
88
+ const rankIdx = orientation === 'white' ? 7 - row : row;
89
+
90
+ // String.fromCharCode not available in worklets — use lookup
91
+ const files = 'abcdefgh';
92
+ return `${files[fileIdx]}${rankIdx + 1}`;
93
+ }
94
+
95
+ /**
96
+ * Manages the piece list derived from FEN, with stable IDs for React keys.
97
+ *
98
+ * Stable IDs prevent unmount/remount cycles when pieces change position.
99
+ * A piece keeps its ID as long as it exists on the board — only capture
100
+ * (removal) or promotion (code change) creates a new ID.
101
+ */
102
+ export function useBoardPieces(fen: string): BoardPiece[] {
103
+ // Track piece-code counters across renders for stable ID assignment
104
+ const idCounterRef = useRef<Record<string, number>>({});
105
+ const prevPiecesRef = useRef<BoardPiece[]>([]);
106
+
107
+ return useMemo(() => {
108
+ const parsed = parseFenPieces(fen);
109
+ const prev = prevPiecesRef.current;
110
+ const prevBySquare = new Map(prev.map((p) => [p.square, p]));
111
+
112
+ // Try to reuse IDs from previous render:
113
+ // 1. Same code on same square -> keep ID (piece didn't move)
114
+ // 2. Same code moved to a new square -> find unmatched previous piece of same code
115
+ const usedPrevIds = new Set<string>();
116
+ const result: BoardPiece[] = [];
117
+
118
+ // First pass: exact square matches (piece stayed or appeared on same square)
119
+ const unmatched: ParsedPiece[] = [];
120
+ for (const p of parsed) {
121
+ const existing = prevBySquare.get(p.square);
122
+ if (existing && existing.code === p.code && !usedPrevIds.has(existing.id)) {
123
+ usedPrevIds.add(existing.id);
124
+ result.push({ ...p, id: existing.id });
125
+ } else {
126
+ unmatched.push(p);
127
+ }
128
+ }
129
+
130
+ // Second pass: match unmatched pieces by code to previous pieces that moved
131
+ for (const p of unmatched) {
132
+ let matchedId: string | null = null;
133
+
134
+ for (const prevPiece of prev) {
135
+ if (
136
+ prevPiece.code === p.code &&
137
+ !usedPrevIds.has(prevPiece.id)
138
+ ) {
139
+ matchedId = prevPiece.id;
140
+ usedPrevIds.add(prevPiece.id);
141
+ break;
142
+ }
143
+ }
144
+
145
+ if (matchedId) {
146
+ result.push({ ...p, id: matchedId });
147
+ } else {
148
+ // New piece (promotion, or first render) — assign fresh ID
149
+ const counter = idCounterRef.current;
150
+ counter[p.code] = (counter[p.code] ?? 0) + 1;
151
+ result.push({ ...p, id: `${p.code}-${counter[p.code]}` });
152
+ }
153
+ }
154
+
155
+ prevPiecesRef.current = result;
156
+ return result;
157
+ }, [fen]);
158
+ }
@@ -1,120 +1,120 @@
1
- import { useRef, useCallback, useMemo } from 'react';
2
- import { Chess } from 'chess.js';
3
- import type { Square } from 'chess.js';
4
-
5
- import type { ChessColor, BoardPiece, LegalMoveTarget } from './types';
6
-
7
- type MoveResult = {
8
- /** Whether the move was applied to the internal chess.js instance */
9
- applied: boolean;
10
- /** The new FEN after the move (if applied) */
11
- fen?: string;
12
- };
13
-
14
- type BoardStateReturn = {
15
- /** Get legal moves for a piece on the given square */
16
- getLegalMoves: (square: string) => LegalMoveTarget[];
17
- /** Check if a given square has a piece belonging to the active player */
18
- isPlayerPiece: (square: string, pieces: BoardPiece[], player: ChessColor | 'both') => boolean;
19
- /** Apply a move to the internal chess state. Returns the new FEN if valid. */
20
- applyMove: (from: string, to: string, promotion?: string) => MoveResult;
21
- /** Undo the last move on the internal chess state */
22
- undoMove: () => string | null;
23
- /** Load a new FEN into the internal chess state */
24
- loadFen: (fen: string) => void;
25
- /** Get the current FEN from internal state */
26
- getFen: () => string;
27
- /** Get the current turn from internal state */
28
- getTurn: () => 'w' | 'b';
29
- /** Check if the current side to move is in check */
30
- isInCheck: () => boolean;
31
- };
32
-
33
- /**
34
- * Manages the internal chess.js instance for legal move validation.
35
- *
36
- * This mirrors the visual board state. When the parent passes a new FEN,
37
- * the internal chess.js is synced. Legal move queries and move application
38
- * happen against this instance.
39
- *
40
- * The chess.js instance lives in a ref — no React state, no re-renders.
41
- */
42
- export function useBoardState(initialFen: string): BoardStateReturn {
43
- const chessRef = useRef<Chess>(null!);
44
- if (!chessRef.current) chessRef.current = new Chess(initialFen);
45
-
46
- const getLegalMoves = useCallback((square: string): LegalMoveTarget[] => {
47
- try {
48
- const moves = chessRef.current.moves({ square: square as Square, verbose: true });
49
- // Deduplicate by target square — chess.js returns 4 promotion variants
50
- // (q, r, b, n) per target square, but we only need one dot per square.
51
- const seen = new Set<string>();
52
- const result: LegalMoveTarget[] = [];
53
- for (const m of moves) {
54
- if (seen.has(m.to)) continue;
55
- seen.add(m.to);
56
- result.push({ square: m.to, isCapture: m.captured !== undefined });
57
- }
58
- return result;
59
- } catch {
60
- return [];
61
- }
62
- }, []);
63
-
64
- const isPlayerPiece = useCallback(
65
- (square: string, pieces: BoardPiece[], player: ChessColor | 'both'): boolean => {
66
- const piece = pieces.find((p) => p.square === square);
67
- if (!piece) return false;
68
-
69
- if (player === 'both') return true;
70
-
71
- const pieceColor: ChessColor = piece.color === 'w' ? 'white' : 'black';
72
- return pieceColor === player;
73
- },
74
- [],
75
- );
76
-
77
- const applyMove = useCallback((from: string, to: string, promotion?: string): MoveResult => {
78
- try {
79
- chessRef.current.move({
80
- from: from as Square,
81
- to: to as Square,
82
- promotion: promotion as 'q' | 'r' | 'b' | 'n' | undefined,
83
- });
84
- return { applied: true, fen: chessRef.current.fen() };
85
- } catch {
86
- return { applied: false };
87
- }
88
- }, []);
89
-
90
- const undoMove = useCallback((): string | null => {
91
- const result = chessRef.current.undo();
92
- return result ? chessRef.current.fen() : null;
93
- }, []);
94
-
95
- const loadFen = useCallback((fen: string) => {
96
- chessRef.current.load(fen);
97
- }, []);
98
-
99
- const getFen = useCallback(() => chessRef.current.fen(), []);
100
-
101
- const getTurn = useCallback(() => chessRef.current.turn(), []);
102
-
103
- const isInCheck = useCallback(() => chessRef.current.isCheck(), []);
104
-
105
- // Stable reference so Board's useEffect([fen, boardState]) only re-runs
106
- // when the fen prop changes — not on every render.
107
- return useMemo(
108
- () => ({
109
- getLegalMoves,
110
- isPlayerPiece,
111
- applyMove,
112
- undoMove,
113
- loadFen,
114
- getFen,
115
- getTurn,
116
- isInCheck,
117
- }),
118
- [getLegalMoves, isPlayerPiece, applyMove, undoMove, loadFen, getFen, getTurn, isInCheck],
119
- );
120
- }
1
+ import { useRef, useCallback, useMemo } from 'react';
2
+ import { Chess } from 'chess.js';
3
+ import type { Square } from 'chess.js';
4
+
5
+ import type { ChessColor, BoardPiece, LegalMoveTarget } from './types';
6
+
7
+ type MoveResult = {
8
+ /** Whether the move was applied to the internal chess.js instance */
9
+ applied: boolean;
10
+ /** The new FEN after the move (if applied) */
11
+ fen?: string;
12
+ };
13
+
14
+ type BoardStateReturn = {
15
+ /** Get legal moves for a piece on the given square */
16
+ getLegalMoves: (square: string) => LegalMoveTarget[];
17
+ /** Check if a given square has a piece belonging to the active player */
18
+ isPlayerPiece: (square: string, pieces: BoardPiece[], player: ChessColor | 'both') => boolean;
19
+ /** Apply a move to the internal chess state. Returns the new FEN if valid. */
20
+ applyMove: (from: string, to: string, promotion?: string) => MoveResult;
21
+ /** Undo the last move on the internal chess state */
22
+ undoMove: () => string | null;
23
+ /** Load a new FEN into the internal chess state */
24
+ loadFen: (fen: string) => void;
25
+ /** Get the current FEN from internal state */
26
+ getFen: () => string;
27
+ /** Get the current turn from internal state */
28
+ getTurn: () => 'w' | 'b';
29
+ /** Check if the current side to move is in check */
30
+ isInCheck: () => boolean;
31
+ };
32
+
33
+ /**
34
+ * Manages the internal chess.js instance for legal move validation.
35
+ *
36
+ * This mirrors the visual board state. When the parent passes a new FEN,
37
+ * the internal chess.js is synced. Legal move queries and move application
38
+ * happen against this instance.
39
+ *
40
+ * The chess.js instance lives in a ref — no React state, no re-renders.
41
+ */
42
+ export function useBoardState(initialFen: string): BoardStateReturn {
43
+ const chessRef = useRef<Chess>(null!);
44
+ if (!chessRef.current) chessRef.current = new Chess(initialFen);
45
+
46
+ const getLegalMoves = useCallback((square: string): LegalMoveTarget[] => {
47
+ try {
48
+ const moves = chessRef.current.moves({ square: square as Square, verbose: true });
49
+ // Deduplicate by target square — chess.js returns 4 promotion variants
50
+ // (q, r, b, n) per target square, but we only need one dot per square.
51
+ const seen = new Set<string>();
52
+ const result: LegalMoveTarget[] = [];
53
+ for (const m of moves) {
54
+ if (seen.has(m.to)) continue;
55
+ seen.add(m.to);
56
+ result.push({ square: m.to, isCapture: m.captured !== undefined });
57
+ }
58
+ return result;
59
+ } catch {
60
+ return [];
61
+ }
62
+ }, []);
63
+
64
+ const isPlayerPiece = useCallback(
65
+ (square: string, pieces: BoardPiece[], player: ChessColor | 'both'): boolean => {
66
+ const piece = pieces.find((p) => p.square === square);
67
+ if (!piece) return false;
68
+
69
+ if (player === 'both') return true;
70
+
71
+ const pieceColor: ChessColor = piece.color === 'w' ? 'white' : 'black';
72
+ return pieceColor === player;
73
+ },
74
+ [],
75
+ );
76
+
77
+ const applyMove = useCallback((from: string, to: string, promotion?: string): MoveResult => {
78
+ try {
79
+ chessRef.current.move({
80
+ from: from as Square,
81
+ to: to as Square,
82
+ promotion: promotion as 'q' | 'r' | 'b' | 'n' | undefined,
83
+ });
84
+ return { applied: true, fen: chessRef.current.fen() };
85
+ } catch {
86
+ return { applied: false };
87
+ }
88
+ }, []);
89
+
90
+ const undoMove = useCallback((): string | null => {
91
+ const result = chessRef.current.undo();
92
+ return result ? chessRef.current.fen() : null;
93
+ }, []);
94
+
95
+ const loadFen = useCallback((fen: string) => {
96
+ chessRef.current.load(fen);
97
+ }, []);
98
+
99
+ const getFen = useCallback(() => chessRef.current.fen(), []);
100
+
101
+ const getTurn = useCallback(() => chessRef.current.turn(), []);
102
+
103
+ const isInCheck = useCallback(() => chessRef.current.isCheck(), []);
104
+
105
+ // Stable reference so Board's useEffect([fen, boardState]) only re-runs
106
+ // when the fen prop changes — not on every render.
107
+ return useMemo(
108
+ () => ({
109
+ getLegalMoves,
110
+ isPlayerPiece,
111
+ applyMove,
112
+ undoMove,
113
+ loadFen,
114
+ getFen,
115
+ getTurn,
116
+ isInCheck,
117
+ }),
118
+ [getLegalMoves, isPlayerPiece, applyMove, undoMove, loadFen, getFen, getTurn, isInCheck],
119
+ );
120
+ }