bitboard-chess 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +49 -0
  2. package/index.mjs +672 -0
  3. package/package.json +22 -0
package/README.md ADDED
@@ -0,0 +1,49 @@
1
+ # bitboard-chess
2
+
3
+ Lightweight bitboard chess engine for position updates. **No move validation** — assumes validated input (e.g. from replaying PGN games). Supports castling, en passant, promotions, full FEN, and deterministic Zobrist hashing.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install bitboard-chess
9
+ ```
10
+
11
+ ## Usage (ESM)
12
+
13
+ ```js
14
+ import BitboardChess from 'bitboard-chess';
15
+
16
+ const board = new BitboardChess();
17
+
18
+ // Apply moves by SAN (Standard Algebraic Notation)
19
+ board.makeMoveSAN('e4');
20
+ board.makeMoveSAN('e5');
21
+ board.makeMoveSAN('Nf3');
22
+
23
+ console.log(board.toFEN());
24
+ // rnbqkbnr/pppppppp/8/8/4P3/5N2/PPPP1PPP/RNBQKBR1 b Qkq - 1 2
25
+
26
+ // Or with raw move objects: { from, to, promotion?, castle?, enpassant? }
27
+ // Squares are 0–63 (a1=0, h8=63).
28
+ board.makeMove({ from: 12, to: 28 }); // e2-e4
29
+
30
+ // Load from FEN
31
+ board.loadFromFEN('rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1');
32
+
33
+ // Deterministic Zobrist key (bigint) for the current position
34
+ const key = board.getZobristKey();
35
+ ```
36
+
37
+ ## API
38
+
39
+ - **`new BitboardChess()`** — Start from initial position.
40
+ - **`makeMove(move)`** — Apply a move object `{ from, to, promotion?, castle?, enpassant? }`. No validation.
41
+ - **`makeMoveSAN(san)`** — Apply a move by SAN string. Returns `true`/`false`. No legality check.
42
+ - **`resolveSAN(san)`** — Resolve SAN to a move object, or `null` if ambiguous/unresolvable.
43
+ - **`loadFromFEN(fen)`** — Set position from FEN (piece placement, side, castling, en passant, halfmove, fullmove). No validation.
44
+ - **`toFEN()`** — Return current position as FEN string.
45
+ - **`getZobristKey()`** — Return deterministic Zobrist key (bigint) for the current position.
46
+
47
+ ## License
48
+
49
+ MIT
package/index.mjs ADDED
@@ -0,0 +1,672 @@
1
+ // ============================================================
2
+ // Bitboard Chess Engine Core — no move validation.
3
+ // Assumes validated input (e.g. from replaying PGN games). Supports:
4
+ // castling, en passant, promotions, full FEN. Used to generate FEN
5
+ // for the same move sequence as chess.js; final position must match.
6
+ // ============================================================
7
+
8
+ const WHITE = 0;
9
+ const BLACK = 1;
10
+
11
+ const bit = (sq) => 1n << BigInt(sq);
12
+
13
+ // Precomputed attack tables
14
+ const knightAttacks = new Array(64).fill(0n);
15
+ const kingAttacks = new Array(64).fill(0n);
16
+ const pawnAttacks = [new Array(64).fill(0n), new Array(64).fill(0n)];
17
+
18
+ function inBoard(f, r) {
19
+ return f >= 0 && f < 8 && r >= 0 && r < 8;
20
+ }
21
+
22
+ function initAttackTables() {
23
+ for (let sq = 0; sq < 64; sq++) {
24
+ const f = sq % 8;
25
+ const r = Math.floor(sq / 8);
26
+
27
+ // Knight
28
+ const kD = [
29
+ [1, 2], [2, 1], [2, -1], [1, -2],
30
+ [-1, -2], [-2, -1], [-2, 1], [-1, 2]
31
+ ];
32
+ let bb = 0n;
33
+ for (const [df, dr] of kD) {
34
+ const nf = f + df, nr = r + dr;
35
+ if (inBoard(nf, nr)) bb |= bit(nr * 8 + nf);
36
+ }
37
+ knightAttacks[sq] = bb;
38
+
39
+ // King
40
+ const gD = [
41
+ [1, 0], [1, 1], [0, 1], [-1, 1],
42
+ [-1, 0], [-1, -1], [0, -1], [1, -1]
43
+ ];
44
+ bb = 0n;
45
+ for (const [df, dr] of gD) {
46
+ const nf = f + df, nr = r + dr;
47
+ if (inBoard(nf, nr)) bb |= bit(nr * 8 + nf);
48
+ }
49
+ kingAttacks[sq] = bb;
50
+
51
+ // Pawn attacks
52
+ // White
53
+ let w = 0n;
54
+ if (inBoard(f - 1, r + 1)) w |= bit((r + 1) * 8 + (f - 1));
55
+ if (inBoard(f + 1, r + 1)) w |= bit((r + 1) * 8 + (f + 1));
56
+ pawnAttacks[WHITE][sq] = w;
57
+
58
+ // Black
59
+ let b = 0n;
60
+ if (inBoard(f - 1, r - 1)) b |= bit((r - 1) * 8 + (f - 1));
61
+ if (inBoard(f + 1, r - 1)) b |= bit((r - 1) * 8 + (f + 1));
62
+ pawnAttacks[BLACK][sq] = b;
63
+ }
64
+ }
65
+ initAttackTables();
66
+
67
+ // ============================================================
68
+ // Sliding piece attacks from a square (for SAN resolution)
69
+ // ============================================================
70
+ function getRookAttacks(sq, occ) {
71
+ const f = sq % 8;
72
+ const r = (sq / 8) | 0;
73
+ let bb = 0n;
74
+ for (let nf = f + 1; nf < 8; nf++) {
75
+ const s = r * 8 + nf;
76
+ bb |= bit(s);
77
+ if (occ & bit(s)) break;
78
+ }
79
+ for (let nf = f - 1; nf >= 0; nf--) {
80
+ const s = r * 8 + nf;
81
+ bb |= bit(s);
82
+ if (occ & bit(s)) break;
83
+ }
84
+ for (let nr = r + 1; nr < 8; nr++) {
85
+ const s = nr * 8 + f;
86
+ bb |= bit(s);
87
+ if (occ & bit(s)) break;
88
+ }
89
+ for (let nr = r - 1; nr >= 0; nr--) {
90
+ const s = nr * 8 + f;
91
+ bb |= bit(s);
92
+ if (occ & bit(s)) break;
93
+ }
94
+ return bb;
95
+ }
96
+
97
+ function getBishopAttacks(sq, occ) {
98
+ const f = sq % 8;
99
+ const r = (sq / 8) | 0;
100
+ let bb = 0n;
101
+ for (let d = 1; d < 8; d++) {
102
+ if (f + d >= 8 || r + d >= 8) break;
103
+ const s = (r + d) * 8 + (f + d);
104
+ bb |= bit(s);
105
+ if (occ & bit(s)) break;
106
+ }
107
+ for (let d = 1; d < 8; d++) {
108
+ if (f - d < 0 || r + d >= 8) break;
109
+ const s = (r + d) * 8 + (f - d);
110
+ bb |= bit(s);
111
+ if (occ & bit(s)) break;
112
+ }
113
+ for (let d = 1; d < 8; d++) {
114
+ if (f + d >= 8 || r - d < 0) break;
115
+ const s = (r - d) * 8 + (f + d);
116
+ bb |= bit(s);
117
+ if (occ & bit(s)) break;
118
+ }
119
+ for (let d = 1; d < 8; d++) {
120
+ if (f - d < 0 || r - d < 0) break;
121
+ const s = (r - d) * 8 + (f - d);
122
+ bb |= bit(s);
123
+ if (occ & bit(s)) break;
124
+ }
125
+ return bb;
126
+ }
127
+
128
+ /** "e4" -> 28 (a1=0, h8=63). */
129
+ function squareToIndex(sqStr) {
130
+ const file = sqStr.charCodeAt(0) - 97;
131
+ const rank = parseInt(sqStr[1], 10) - 1;
132
+ return rank * 8 + file;
133
+ }
134
+
135
+ /** Squares from which a pawn of color can capture to toSq (bitboard). */
136
+ function pawnCaptureSources(toSq, color) {
137
+ const f = toSq % 8;
138
+ const r = (toSq / 8) | 0;
139
+ let bb = 0n;
140
+ if (color === WHITE) {
141
+ if (r >= 1 && f >= 1) bb |= bit((r - 1) * 8 + (f - 1));
142
+ if (r >= 1 && f < 7) bb |= bit((r - 1) * 8 + (f + 1));
143
+ } else {
144
+ if (r < 7 && f >= 1) bb |= bit((r + 1) * 8 + (f - 1));
145
+ if (r < 7 && f < 7) bb |= bit((r + 1) * 8 + (f + 1));
146
+ }
147
+ return bb;
148
+ }
149
+
150
+ /** Filter candidate bitboard by optional file/rank; return single sq index or -1. */
151
+ function filterDisamb(candidates, disambFile, disambRank) {
152
+ let bb = candidates;
153
+ if (disambFile !== undefined) {
154
+ const fileIdx = disambFile.charCodeAt(0) - 97;
155
+ const fileMask = (1n << BigInt(fileIdx)) | (1n << BigInt(8 + fileIdx)) | (1n << BigInt(16 + fileIdx)) | (1n << BigInt(24 + fileIdx)) | (1n << BigInt(32 + fileIdx)) | (1n << BigInt(40 + fileIdx)) | (1n << BigInt(48 + fileIdx)) | (1n << BigInt(56 + fileIdx));
156
+ bb = bb & fileMask;
157
+ }
158
+ if (disambRank !== undefined) {
159
+ const rankIdx = parseInt(disambRank, 10) - 1;
160
+ bb = bb & (0xffn << BigInt(rankIdx * 8));
161
+ }
162
+ let found = -1;
163
+ for (let sq = 0; sq < 64; sq++) {
164
+ if (bb & bit(sq)) {
165
+ if (found >= 0) return -1;
166
+ found = sq;
167
+ }
168
+ }
169
+ return found;
170
+ }
171
+
172
+ /** Parse SAN to { piece, targetIndex, disambFile, disambRank, promotion, castle }. piece: 'N'|'B'|'R'|'Q'|'K'|null (pawn). */
173
+ function parseSAN(san) {
174
+ const s = String(san).trim();
175
+ if (s === 'O-O' || s === 'O-O-O') {
176
+ return { piece: null, targetIndex: -1, disambFile: undefined, disambRank: undefined, promotion: undefined, castle: s === 'O-O' ? 'K' : 'Q' };
177
+ }
178
+ const targetPromo = s.match(/([a-h][1-8])(?:=([NBRQ]))?$/);
179
+ if (!targetPromo) return null;
180
+ const targetStr = targetPromo[1];
181
+ const promotion = targetPromo[2] ? targetPromo[2].toLowerCase() : undefined;
182
+ const targetIndex = squareToIndex(targetStr);
183
+ let rest = s.slice(0, s.length - targetPromo[0].length).replace(/x$/, '');
184
+ let piece = null;
185
+ let disambFile;
186
+ let disambRank;
187
+ if (rest.length > 0 && 'NBRQK'.includes(rest[0])) {
188
+ piece = rest[0];
189
+ rest = rest.slice(1);
190
+ }
191
+ if (rest.length >= 1) {
192
+ if (rest[0] >= 'a' && rest[0] <= 'h') disambFile = rest[0];
193
+ if (rest[rest.length - 1] >= '1' && rest[rest.length - 1] <= '8') disambRank = rest[rest.length - 1];
194
+ if (rest.length === 2 && rest[0] >= 'a' && rest[0] <= 'h' && rest[1] >= '1' && rest[1] <= '8') {
195
+ disambFile = rest[0];
196
+ disambRank = rest[1];
197
+ }
198
+ }
199
+ return { piece, targetIndex, disambFile, disambRank, promotion, castle: undefined };
200
+ }
201
+
202
+ // ============================================================
203
+ // Zobrist hashing (deterministic, seeded)
204
+ // ============================================================
205
+ function mulberry32(seed) {
206
+ return function () {
207
+ let t = (seed += 0x6d2b79f5);
208
+ t = Math.imul(t ^ (t >>> 15), t | 1);
209
+ t ^= t >>> 7;
210
+ return (t ^ (t >>> 12)) >>> 0;
211
+ };
212
+ }
213
+
214
+ function random64(prng) {
215
+ const lo = prng();
216
+ const hi = prng();
217
+ return (BigInt(hi) << 32n) | BigInt(lo);
218
+ }
219
+
220
+ const ZOBRIST_PIECES = [];
221
+ const ZOBRIST_CASTLE = [];
222
+ const ZOBRIST_EP = [];
223
+ const ZOBRIST_SIDE = { value: 0n };
224
+
225
+ (function initZobrist() {
226
+ const prng = mulberry32(0x5eed);
227
+ for (let sq = 0; sq < 64; sq++) {
228
+ ZOBRIST_PIECES[sq] = [];
229
+ for (let pt = 0; pt < 12; pt++) ZOBRIST_PIECES[sq][pt] = random64(prng);
230
+ }
231
+ ZOBRIST_SIDE.value = random64(prng);
232
+ for (let i = 0; i < 4; i++) ZOBRIST_CASTLE.push(random64(prng));
233
+ for (let f = 0; f < 8; f++) ZOBRIST_EP.push(random64(prng));
234
+ })();
235
+
236
+ // ============================================================
237
+ // Engine Class
238
+ // ============================================================
239
+
240
+ class BitboardChess {
241
+ constructor() {
242
+ this.reset();
243
+ }
244
+
245
+ reset() {
246
+ // Piece bitboards
247
+ this.pawns = [0n, 0n];
248
+ this.knights = [0n, 0n];
249
+ this.bishops = [0n, 0n];
250
+ this.rooks = [0n, 0n];
251
+ this.queens = [0n, 0n];
252
+ this.kings = [0n, 0n];
253
+
254
+ // Game state
255
+ this.sideToMove = WHITE;
256
+ this.castling = "KQkq";
257
+ this.enPassant = -1;
258
+ this.halfmove = 0;
259
+ this.fullmove = 1;
260
+
261
+ this._setInitialPosition();
262
+ }
263
+
264
+ _setInitialPosition() {
265
+ this.pawns[WHITE] = 0x000000000000FF00n;
266
+ this.pawns[BLACK] = 0x00FF000000000000n;
267
+
268
+ this.rooks[WHITE] = 0x0000000000000081n;
269
+ this.rooks[BLACK] = 0x8100000000000000n;
270
+
271
+ this.knights[WHITE] = 0x0000000000000042n;
272
+ this.knights[BLACK] = 0x4200000000000000n;
273
+
274
+ this.bishops[WHITE] = 0x0000000000000024n;
275
+ this.bishops[BLACK] = 0x2400000000000000n;
276
+
277
+ this.queens[WHITE] = 0x0000000000000008n;
278
+ this.queens[BLACK] = 0x0800000000000000n;
279
+
280
+ this.kings[WHITE] = 0x0000000000000010n;
281
+ this.kings[BLACK] = 0x1000000000000000n;
282
+ }
283
+
284
+ /**
285
+ * Set position from FEN (piece placement, side, castling, en passant).
286
+ * Used for ECO Zobrist indexing and any FEN→key use. No validation.
287
+ */
288
+ loadFromFEN(fen) {
289
+ if (!fen || typeof fen !== 'string') return;
290
+ const tokens = fen.trim().split(/\s+/);
291
+ const placement = tokens[0];
292
+ if (!placement) return;
293
+ const ranks = placement.split('/');
294
+ if (ranks.length !== 8) return;
295
+
296
+ this.pawns[WHITE] = 0n;
297
+ this.pawns[BLACK] = 0n;
298
+ this.knights[WHITE] = 0n;
299
+ this.knights[BLACK] = 0n;
300
+ this.bishops[WHITE] = 0n;
301
+ this.bishops[BLACK] = 0n;
302
+ this.rooks[WHITE] = 0n;
303
+ this.rooks[BLACK] = 0n;
304
+ this.queens[WHITE] = 0n;
305
+ this.queens[BLACK] = 0n;
306
+ this.kings[WHITE] = 0n;
307
+ this.kings[BLACK] = 0n;
308
+
309
+ for (let r = 0; r < 8; r++) {
310
+ const rankStr = ranks[7 - r];
311
+ let f = 0;
312
+ for (const ch of rankStr) {
313
+ if (f >= 8) break;
314
+ if (ch >= '1' && ch <= '8') {
315
+ f += parseInt(ch, 10);
316
+ continue;
317
+ }
318
+ const sq = r * 8 + f;
319
+ const b = bit(sq);
320
+ if (ch === 'P') this.pawns[WHITE] |= b;
321
+ else if (ch === 'N') this.knights[WHITE] |= b;
322
+ else if (ch === 'B') this.bishops[WHITE] |= b;
323
+ else if (ch === 'R') this.rooks[WHITE] |= b;
324
+ else if (ch === 'Q') this.queens[WHITE] |= b;
325
+ else if (ch === 'K') this.kings[WHITE] |= b;
326
+ else if (ch === 'p') this.pawns[BLACK] |= b;
327
+ else if (ch === 'n') this.knights[BLACK] |= b;
328
+ else if (ch === 'b') this.bishops[BLACK] |= b;
329
+ else if (ch === 'r') this.rooks[BLACK] |= b;
330
+ else if (ch === 'q') this.queens[BLACK] |= b;
331
+ else if (ch === 'k') this.kings[BLACK] |= b;
332
+ f++;
333
+ }
334
+ }
335
+
336
+ this.sideToMove = tokens[1] === 'b' ? BLACK : WHITE;
337
+ this.castling = (tokens[2] && tokens[2] !== '-') ? tokens[2] : '';
338
+ const ep = tokens[3];
339
+ if (ep && ep !== '-') {
340
+ const file = ep.charCodeAt(0) - 97;
341
+ const rank = parseInt(ep[1], 10) - 1;
342
+ if (file >= 0 && file < 8 && rank >= 0 && rank < 8) this.enPassant = rank * 8 + file;
343
+ else this.enPassant = -1;
344
+ } else {
345
+ this.enPassant = -1;
346
+ }
347
+ this.halfmove = parseInt(tokens[4], 10) || 0;
348
+ this.fullmove = parseInt(tokens[5], 10) || 1;
349
+ }
350
+
351
+ // ============================================================
352
+ // Utility
353
+ // ============================================================
354
+
355
+ occupancy(color) {
356
+ return (
357
+ this.pawns[color] |
358
+ this.knights[color] |
359
+ this.bishops[color] |
360
+ this.rooks[color] |
361
+ this.queens[color] |
362
+ this.kings[color]
363
+ );
364
+ }
365
+
366
+ allOcc() {
367
+ return this.occupancy(WHITE) | this.occupancy(BLACK);
368
+ }
369
+
370
+ // ============================================================
371
+ // SAN resolution (reachability + disambiguation; no legality check)
372
+ // ============================================================
373
+
374
+ /**
375
+ * Resolve SAN to move object { from, to, promotion?, castle?, enpassant? }.
376
+ * Returns null if SAN cannot be resolved (ambiguous or no matching piece).
377
+ */
378
+ resolveSAN(san) {
379
+ const p = parseSAN(san);
380
+ if (!p) return null;
381
+ const side = this.sideToMove;
382
+ const occ = this.allOcc();
383
+ const toSq = p.targetIndex;
384
+
385
+ if (p.castle) {
386
+ const fromSq = side === WHITE ? 4 : 60;
387
+ const toSqKing = p.castle === 'K' ? (side === WHITE ? 6 : 62) : (side === WHITE ? 2 : 58);
388
+ return { from: fromSq, to: toSqKing, castle: p.castle };
389
+ }
390
+
391
+ let candidates;
392
+ if (p.piece === 'K') {
393
+ candidates = kingAttacks[toSq] & this.kings[side];
394
+ } else if (p.piece === 'N') {
395
+ candidates = knightAttacks[toSq] & this.knights[side];
396
+ } else if (p.piece === 'R') {
397
+ candidates = getRookAttacks(toSq, occ) & this.rooks[side];
398
+ } else if (p.piece === 'B') {
399
+ candidates = getBishopAttacks(toSq, occ) & this.bishops[side];
400
+ } else if (p.piece === 'Q') {
401
+ candidates = (getRookAttacks(toSq, occ) | getBishopAttacks(toSq, occ)) & this.queens[side];
402
+ } else {
403
+ // Pawn
404
+ if (p.disambFile !== undefined) {
405
+ // Capture (e.g. exd5, exd6 e.p.): squares from which a pawn can capture to toSq
406
+ candidates = pawnCaptureSources(toSq, side) & this.pawns[side];
407
+ const fileIdx = p.disambFile.charCodeAt(0) - 97;
408
+ const fileMask = (1n << BigInt(fileIdx)) | (1n << BigInt(8 + fileIdx)) | (1n << BigInt(16 + fileIdx)) | (1n << BigInt(24 + fileIdx)) | (1n << BigInt(32 + fileIdx)) | (1n << BigInt(40 + fileIdx)) | (1n << BigInt(48 + fileIdx)) | (1n << BigInt(56 + fileIdx));
409
+ candidates = candidates & fileMask;
410
+ } else {
411
+ // Push
412
+ const oneBack = side === WHITE ? toSq - 8 : toSq + 8;
413
+ const twoBack = side === WHITE ? toSq - 16 : toSq + 16;
414
+ const toRank = (toSq / 8) | 0;
415
+ if (this.pawns[side] & bit(oneBack)) {
416
+ candidates = bit(oneBack);
417
+ } else if ((side === WHITE ? toRank === 3 : toRank === 4) && (this.pawns[side] & bit(twoBack)) && !(occ & bit(oneBack))) {
418
+ candidates = bit(twoBack);
419
+ } else {
420
+ candidates = 0n;
421
+ }
422
+ }
423
+ }
424
+
425
+ let fromSq = filterDisamb(candidates, p.disambFile, p.disambRank);
426
+ if (fromSq < 0 && candidates) {
427
+ let count = 0;
428
+ for (let sq = 0; sq < 64; sq++) {
429
+ if (candidates & bit(sq)) { count++; fromSq = sq; if (count > 1) break; }
430
+ }
431
+ if (count !== 1) fromSq = -1;
432
+ }
433
+ if (fromSq < 0) return null;
434
+
435
+ const move = { from: fromSq, to: toSq };
436
+ if (p.promotion) move.promotion = p.promotion;
437
+ if (p.piece === null && p.disambFile !== undefined && !(occ & bit(toSq))) move.enpassant = true;
438
+ return move;
439
+ }
440
+
441
+ /**
442
+ * Apply a move given in SAN. No validation; assumes valid (e.g. from PGN).
443
+ * Use makeMove(move) for raw move objects.
444
+ */
445
+ makeMoveSAN(san) {
446
+ const move = typeof san === 'string' ? this.resolveSAN(san) : san;
447
+ if (!move) return false;
448
+ this.makeMove(move);
449
+ return true;
450
+ }
451
+
452
+ // ============================================================
453
+ // Move Making (NO VALIDATION)
454
+ // move = { from, to, promotion, castle, enpassant }
455
+ // ============================================================
456
+
457
+ makeMove(move) {
458
+ const side = this.sideToMove;
459
+ const enemy = side ^ 1;
460
+
461
+ const fromBB = bit(move.from);
462
+ const toBB = bit(move.to);
463
+
464
+ // -------------------------
465
+ // 1. Handle castling
466
+ // -------------------------
467
+ if (move.castle) {
468
+ if (move.castle === "K") {
469
+ // King-side
470
+ this.kings[side] ^= fromBB | toBB;
471
+ if (side === WHITE) {
472
+ this.rooks[WHITE] ^= bit(7) | bit(5);
473
+ } else {
474
+ this.rooks[BLACK] ^= bit(63) | bit(61);
475
+ }
476
+ } else {
477
+ // Queen-side
478
+ this.kings[side] ^= fromBB | toBB;
479
+ if (side === WHITE) {
480
+ this.rooks[WHITE] ^= bit(0) | bit(3);
481
+ } else {
482
+ this.rooks[BLACK] ^= bit(56) | bit(59);
483
+ }
484
+ }
485
+
486
+ // Remove castling rights
487
+ this.castling = this.castling.replace(side === WHITE ? /K|Q/g : /k|q/g, "");
488
+ this._finishMove();
489
+ return;
490
+ }
491
+
492
+ // -------------------------
493
+ // 2. Handle en-passant capture
494
+ // -------------------------
495
+ if (move.enpassant) {
496
+ const capSq = side === WHITE ? move.to - 8 : move.to + 8;
497
+ const capBB = bit(capSq);
498
+ this.pawns[enemy] &= ~capBB;
499
+ }
500
+
501
+ // -------------------------
502
+ // 3. Remove captured piece (normal capture)
503
+ // -------------------------
504
+ const removeAt = (arr) => (arr[enemy] &= ~toBB);
505
+ [this.pawns, this.knights, this.bishops, this.rooks, this.queens, this.kings]
506
+ .forEach(removeAt);
507
+
508
+ // -------------------------
509
+ // 4. Move our piece
510
+ // -------------------------
511
+ const movePiece = (arr) => {
512
+ if (arr[side] & fromBB) {
513
+ arr[side] ^= fromBB;
514
+ arr[side] |= toBB;
515
+ return true;
516
+ }
517
+ return false;
518
+ };
519
+
520
+ let movedPawn = false;
521
+ const movedRook = !!(this.rooks[side] & fromBB);
522
+
523
+ if (movePiece(this.pawns)) movedPawn = true;
524
+ else if (movePiece(this.knights));
525
+ else if (movePiece(this.bishops));
526
+ else if (movePiece(this.rooks));
527
+ else if (movePiece(this.queens));
528
+ else movePiece(this.kings);
529
+
530
+ // -------------------------
531
+ // 5. Promotion
532
+ // -------------------------
533
+ if (move.promotion) {
534
+ // Remove pawn
535
+ this.pawns[side] &= ~toBB;
536
+
537
+ const target =
538
+ move.promotion === "q" ? this.queens :
539
+ move.promotion === "r" ? this.rooks :
540
+ move.promotion === "b" ? this.bishops :
541
+ this.knights;
542
+
543
+ target[side] |= toBB;
544
+ }
545
+
546
+ // -------------------------
547
+ // 6. Update en-passant square
548
+ // -------------------------
549
+ if (movedPawn && Math.abs(move.to - move.from) === 16) {
550
+ this.enPassant = (move.from + move.to) >> 1;
551
+ } else {
552
+ this.enPassant = -1;
553
+ }
554
+
555
+ // -------------------------
556
+ // 7. Remove castling rights if king or rook moved
557
+ // -------------------------
558
+ if (fromBB & this.kings[side]) {
559
+ this.castling = this.castling.replace(side === WHITE ? /K|Q/g : /k|q/g, "");
560
+ }
561
+ if (movedRook) {
562
+ if (move.from === 0) this.castling = this.castling.replace("Q", "");
563
+ if (move.from === 7) this.castling = this.castling.replace("K", "");
564
+ if (move.from === 56) this.castling = this.castling.replace("q", "");
565
+ if (move.from === 63) this.castling = this.castling.replace("k", "");
566
+ }
567
+
568
+ // -------------------------
569
+ // 8. Finish move
570
+ // -------------------------
571
+ this._finishMove();
572
+ }
573
+
574
+ _finishMove() {
575
+ this.sideToMove ^= 1;
576
+ if (this.sideToMove === WHITE) this.fullmove++;
577
+ }
578
+
579
+ // ============================================================
580
+ // Zobrist key (from current position; deterministic)
581
+ // ============================================================
582
+
583
+ getZobristKey() {
584
+ let key = 0n;
585
+ const pieceSets = [
586
+ [this.pawns, 0, 6],
587
+ [this.knights, 1, 7],
588
+ [this.bishops, 2, 8],
589
+ [this.rooks, 3, 9],
590
+ [this.queens, 4, 10],
591
+ [this.kings, 5, 11],
592
+ ];
593
+ for (let sq = 0; sq < 64; sq++) {
594
+ const b = bit(sq);
595
+ for (const [arr, wPt, bPt] of pieceSets) {
596
+ if (arr[WHITE] & b) {
597
+ key ^= ZOBRIST_PIECES[sq][wPt];
598
+ break;
599
+ }
600
+ if (arr[BLACK] & b) {
601
+ key ^= ZOBRIST_PIECES[sq][bPt];
602
+ break;
603
+ }
604
+ }
605
+ }
606
+ if (this.sideToMove === BLACK) key ^= ZOBRIST_SIDE.value;
607
+ const castlingIndex = { K: 0, Q: 1, k: 2, q: 3 };
608
+ for (const c of this.castling) key ^= ZOBRIST_CASTLE[castlingIndex[c]];
609
+ if (this.enPassant >= 0) key ^= ZOBRIST_EP[this.enPassant % 8];
610
+ return key;
611
+ }
612
+
613
+ // ============================================================
614
+ // FEN
615
+ // ============================================================
616
+
617
+ toFEN() {
618
+ let fen = "";
619
+
620
+ for (let r = 7; r >= 0; r--) {
621
+ let empty = 0;
622
+ for (let f = 0; f < 8; f++) {
623
+ const sq = r * 8 + f;
624
+ const bb = bit(sq);
625
+
626
+ let piece = null;
627
+
628
+ const check = (arr, w, b) =>
629
+ arr[WHITE] & bb ? w :
630
+ arr[BLACK] & bb ? b : null;
631
+
632
+ piece =
633
+ check(this.pawns, "P", "p") ||
634
+ check(this.knights, "N", "n") ||
635
+ check(this.bishops, "B", "b") ||
636
+ check(this.rooks, "R", "r") ||
637
+ check(this.queens, "Q", "q") ||
638
+ check(this.kings, "K", "k");
639
+
640
+ if (!piece) {
641
+ empty++;
642
+ } else {
643
+ if (empty) {
644
+ fen += empty;
645
+ empty = 0;
646
+ }
647
+ fen += piece;
648
+ }
649
+ }
650
+ if (empty) fen += empty;
651
+ if (r > 0) fen += "/";
652
+ }
653
+
654
+ fen += " ";
655
+ fen += this.sideToMove === WHITE ? "w" : "b";
656
+ fen += " ";
657
+ fen += this.castling || "-";
658
+ fen += " ";
659
+ fen += this.enPassant === -1
660
+ ? "-"
661
+ : String.fromCharCode("a".charCodeAt(0) + (this.enPassant % 8)) +
662
+ (Math.floor(this.enPassant / 8) + 1);
663
+ fen += " ";
664
+ fen += this.halfmove;
665
+ fen += " ";
666
+ fen += this.fullmove;
667
+
668
+ return fen;
669
+ }
670
+ }
671
+
672
+ export default BitboardChess;
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "bitboard-chess",
3
+ "version": "0.1.0",
4
+ "description": "Lightweight bitboard chess engine for position updates (FEN, Zobrist, SAN). No move validation; assumes validated input.",
5
+ "type": "module",
6
+ "main": "index.mjs",
7
+ "exports": "./index.mjs",
8
+ "files": ["index.mjs"],
9
+ "scripts": {
10
+ "test": "mocha test/**/*.cjs"
11
+ },
12
+ "keywords": ["chess", "bitboard", "fen", "zobrist"],
13
+ "license": "MIT",
14
+ "devDependencies": {
15
+ "chai": "^4.0.2",
16
+ "chess.js": "^0.10.2",
17
+ "mocha": "^11.1.0"
18
+ },
19
+ "engines": {
20
+ "node": ">= 11.0.0"
21
+ }
22
+ }