From 521712738dbb232e5129ae069148991d2d2d4f5c Mon Sep 17 00:00:00 2001 From: Joshua Thomas Date: Thu, 11 Sep 2025 17:41:47 -0700 Subject: [PATCH 1/3] beefing up unit tests, tracking captured pieces, exposing those via the game clients --- @types/chess/chess.d.ts | 23 ++++++++++- package.json | 2 +- src/algebraicGameClient.js | 4 ++ src/game.js | 26 ++++++++++-- src/simpleGameClient.js | 4 ++ src/uciGameClient.js | 4 ++ test/src/algebraicGameClient.js | 17 ++++++++ test/src/game.js | 64 +++++++++++++++++++++++++++++ test/src/main.js | 8 +++- test/src/simpleGameClient.js | 71 +++++++++++++++++++++++++++++++++ test/src/uciGameClient.js | 16 ++++++++ 11 files changed, 232 insertions(+), 7 deletions(-) diff --git a/@types/chess/chess.d.ts b/@types/chess/chess.d.ts index edb81cf..d1b9633 100644 --- a/@types/chess/chess.d.ts +++ b/@types/chess/chess.d.ts @@ -5,6 +5,7 @@ declare namespace Chess { export function create(opts?: { PGN: boolean }): AlgebraicGameClient export function createSimple(): SimpleGameClient export function fromFEN(fen: string, opts?: { PGN: boolean }): AlgebraicGameClient + export function createUCI(): UCIGameClient interface GameStatus { /** Whether either of the side is under check */ @@ -27,6 +28,11 @@ declare namespace Chess { notatedMoves: Record } + interface UCIGameStatus extends SimpleGameStatus { + /** Hash of next possible moves with key as UCI string and value as src-dest mapping */ + uciMoves: Record + } + interface GameClient extends GameStatus { /** The Game object, which includes the board and move history. */ game: Game @@ -42,6 +48,8 @@ declare namespace Chess { */ move(notation: string): PlayedMove getStatus(): AlgebraicGameStatus | SimpleGameStatus + /** Returns the list of captured pieces in order */ + getCaptureHistory(): Piece[] } interface SimpleGameClient extends GameClient { @@ -57,6 +65,18 @@ declare namespace Chess { getFen(): string } + interface UCIGameClient extends GameStatus { + game: Game + validMoves: ValidMove[] + validation: GameValidation + on(event: ChessEvent, cbk: () => void): void + /** Make a move on the board using UCI notation */ + move(uci: string): PlayedMove + getStatus(): UCIGameStatus + /** Returns the list of captured pieces in order */ + getCaptureHistory(): Piece[] + } + type File = string type Rank = number type ChessEvent = 'check' | 'checkmate' @@ -75,6 +95,7 @@ declare namespace Chess { interface Game { board: ChessBoard + captureHistory: Piece[] moveHistory: Move[] } @@ -100,7 +121,7 @@ declare namespace Chess { } interface NotatedMove { - dest: Square + dest: Squarethis.captureHistory = []; src: Square } diff --git a/package.json b/package.json index 8f36504..9595cac 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "chess", "description": "An algebraic notation driven chess engine that can validate board position and produce a list of viable moves (notated).", - "version": "1.4.0", + "version": "1.5.0", "contributors": [ { "name": "Joshua Thomas", diff --git a/src/algebraicGameClient.js b/src/algebraicGameClient.js index 61e7fe5..d288804 100644 --- a/src/algebraicGameClient.js +++ b/src/algebraicGameClient.js @@ -319,6 +319,10 @@ export class AlgebraicGameClient extends EventEmitter { return this.game.board.getFen(); } + getCaptureHistory () { + return this.game.captureHistory; + } + move (notation, isFuzzy) { let move = null, diff --git a/src/game.js b/src/game.js index 4790b23..a542fb9 100644 --- a/src/game.js +++ b/src/game.js @@ -59,6 +59,7 @@ export class Game extends EventEmitter { super(); this.board = board; + this.captureHistory = []; this.moveHistory = []; } @@ -68,9 +69,22 @@ export class Game extends EventEmitter { game = new Game(board); // handle move and promotion events correctly - board.on('move', addToHistory(game)); + board.on('move', (ev) => { + addToHistory(game)(ev); + if (ev && ev.capturedPiece) { + game.captureHistory.push(ev.capturedPiece); + } + }); + board.on('promote', denotePromotionInHistory(game)); - board.on('undo', removeFromHistory(game)); + + board.on('undo', (ev) => { + removeFromHistory(game)(ev); + if (ev && ev.capturedPiece && game.captureHistory.length > 0) { + // last move was a capture, remove it from capture history + game.captureHistory.pop(); + } + }); return game; } @@ -109,7 +123,13 @@ export class Game extends EventEmitter { i = 0; // handle move and promotion events correctly - board.on('move', addToHistory(game)); + board.on('move', (ev) => { + addToHistory(game)(ev); + if (ev && ev.capturedPiece) { + game.captureHistory.push(ev.capturedPiece); + } + }); + board.on('promote', denotePromotionInHistory(game)); // apply move history diff --git a/src/simpleGameClient.js b/src/simpleGameClient.js index 4d4f65a..e091e43 100644 --- a/src/simpleGameClient.js +++ b/src/simpleGameClient.js @@ -140,6 +140,10 @@ export class SimpleGameClient extends EventEmitter { throw new Error(`Move is invalid (${ src } to ${ dest })`); } + + getCaptureHistory () { + return this.game.captureHistory; + } } export default { SimpleGameClient }; diff --git a/src/uciGameClient.js b/src/uciGameClient.js index b818eea..69d789e 100644 --- a/src/uciGameClient.js +++ b/src/uciGameClient.js @@ -147,6 +147,10 @@ export class UCIGameClient extends EventEmitter { }; } + getCaptureHistory() { + return this.game.captureHistory; + } + move(uci) { let canonical = null, diff --git a/test/src/algebraicGameClient.js b/test/src/algebraicGameClient.js index 9b20c33..657a40b 100644 --- a/test/src/algebraicGameClient.js +++ b/test/src/algebraicGameClient.js @@ -90,6 +90,23 @@ describe('AlgebraicGameClient', () => { assert.strictEqual(gc.game.moveHistory[2].algebraic, 'exd5'); }); + // getCaptureHistory: track captures and undo + it('should expose capture history via getCaptureHistory()', () => { + let gc = AlgebraicGameClient.create(); + + gc.move('e4'); + gc.move('d5'); + const cap = gc.move('exd5'); + + const h1 = gc.getCaptureHistory(); + assert.strictEqual(h1.length, 1); + assert.strictEqual(h1[0].type, PieceType.Pawn); + + cap.undo(); + const h2 = gc.getCaptureHistory(); + assert.strictEqual(h2.length, 0); + }); + // test 2 face pieces with same square destination on different rank and file it('should properly notate two Knights that can occupy same square for their respective moves', () => { let diff --git a/test/src/game.js b/test/src/game.js index 08b0fd7..ed78365 100644 --- a/test/src/game.js +++ b/test/src/game.js @@ -1,5 +1,6 @@ /* eslint no-magic-numbers:0 */ import { assert, describe, it } from 'vitest'; +import { PieceType, SideType } from '../../src/piece.js'; import { Game } from '../../src/game.js'; describe('Game', () => { @@ -160,3 +161,66 @@ describe('Game', () => { // ensure load from moveHistory results in board in appropriate state }); + +describe('Game capture history', () => { + it('should track captures and undo correctly', () => { + const g = Game.create(); + const b = g.board; + + // e2e4, d7d5, e4xd5 + b.move(b.getSquare('e', 2), b.getSquare('e', 4)); + b.move(b.getSquare('d', 7), b.getSquare('d', 5)); + const cap = b.move(b.getSquare('e', 4), b.getSquare('d', 5)); + + // verify capture tracked + if (g.captureHistory.length !== 1) { + throw new Error('captureHistory should contain one capture'); + } + + const c = g.captureHistory[0]; + + if (!c || c.type !== PieceType.Pawn || c.side !== SideType.Black) { + throw new Error('captureHistory should contain captured black pawn'); + } + + // undo and verify capture removed + cap.undo(); + if (g.captureHistory.length !== 0) { + throw new Error('captureHistory should be empty after undo'); + } + }); + + it('should track multiple captures in order', () => { + const g = Game.create(); + const b = g.board; + + // e2e4, d7d5, e4xd5 (capture 1) + b.move(b.getSquare('e', 2), b.getSquare('e', 4)); + b.move(b.getSquare('d', 7), b.getSquare('d', 5)); + b.move(b.getSquare('e', 4), b.getSquare('d', 5)); + + // c7c5, d5xc5 (capture 2) + b.move(b.getSquare('c', 7), b.getSquare('c', 5)); + const cap2 = b.move(b.getSquare('d', 5), b.getSquare('c', 5)); + + if (g.captureHistory.length !== 2) { + throw new Error('captureHistory should contain two captures'); + } + + const [c1, c2] = g.captureHistory; + + if (!c1 || c1.type !== PieceType.Pawn || c1.side !== SideType.Black) { + throw new Error('first capture should be black pawn from d5'); + } + + if (!c2 || c2.type !== PieceType.Pawn || c2.side !== SideType.Black) { + throw new Error('second capture should be black pawn from c5'); + } + + // undo last capture reduces capture history by 1 + cap2.undo(); + if (g.captureHistory.length !== 1) { + throw new Error('captureHistory should have one capture after undoing the last capture'); + } + }); +}); diff --git a/test/src/main.js b/test/src/main.js index 90b9ecd..528fd5f 100644 --- a/test/src/main.js +++ b/test/src/main.js @@ -1,6 +1,6 @@ /* eslint no-magic-numbers:0 */ import { assert, describe, it } from 'vitest'; -import chess, { create, createSimple } from '../../src/main.js'; +import chess, { create, createSimple, createUCI } from '../../src/main.js'; import { AlgebraicGameClient } from '../../src/algebraicGameClient.js'; import { SimpleGameClient } from '../../src/simpleGameClient.js'; @@ -27,5 +27,9 @@ describe('main entry', () => { assert.ok(chess.create() instanceof AlgebraicGameClient); assert.ok(chess.createSimple() instanceof SimpleGameClient); }); -}); + it('named and default export should expose createUCI', () => { + assert.strictEqual(typeof createUCI, 'function'); + assert.strictEqual(typeof chess.createUCI, 'function'); + }); +}); diff --git a/test/src/simpleGameClient.js b/test/src/simpleGameClient.js index c6984c9..2b15271 100644 --- a/test/src/simpleGameClient.js +++ b/test/src/simpleGameClient.js @@ -101,4 +101,75 @@ describe('SimpleGameClient', () => { assert.isDefined(checkResult); assert.strictEqual(checkResult.attackingSquare.piece.type, PieceType.Knight); }); + + // getCaptureHistory: track captures and undo + it('should expose capture history via getCaptureHistory()', () => { + const gc = SimpleGameClient.create(); + gc.move('e2', 'e4'); + gc.move('d7', 'd5'); + const cap = gc.move('e4', 'd5'); + + const h1 = gc.getCaptureHistory(); + assert.strictEqual(h1.length, 1); + assert.strictEqual(h1[0].type, PieceType.Pawn); + + cap.undo(); + const h2 = gc.getCaptureHistory(); + assert.strictEqual(h2.length, 0); + }); + + it('should emit castle event when castling by coordinates', () => { + const gc = SimpleGameClient.create(); + const castleEvents = []; + gc.on('castle', (ev) => castleEvents.push(ev)); + + // clear path for white castle short (e1 -> g1) + gc.game.board.getSquare('f1').piece = null; + gc.game.board.getSquare('g1').piece = null; + + // force update to compute valid moves with cleared path + gc.getStatus(true); + gc.move('e1', 'g1'); + + assert.strictEqual(castleEvents.length, 1); + }); + + it('should handle en passant and emit event', () => { + const gc = SimpleGameClient.create(); + const enPassantEvents = []; + gc.on('enPassant', (ev) => enPassantEvents.push(ev)); + + // Setup: e2e4, a7a6, e4e5, d7d5, e5d6 e.p. + gc.move('e2', 'e4'); + gc.move('a7', 'a6'); + gc.move('e4', 'e5'); + gc.move('d7', 'd5'); + + const m = gc.move('e5', 'd6'); + assert.ok(m.move.enPassant); + assert.strictEqual(enPassantEvents.length, 1); + }); + + it('should handle pawn promotion and emit event', () => { + const gc = SimpleGameClient.create(); + const promoteEvents = []; + gc.on('promote', (ev) => promoteEvents.push(ev)); + + // Setup white pawn on a7 ready to promote, clear a8 and block pieces + gc.game.board.getSquare('a7').piece = null; + gc.game.board.getSquare('a8').piece = null; + gc.game.board.getSquare('b8').piece = null; + gc.game.board.getSquare('c8').piece = null; + gc.game.board.getSquare('d8').piece = null; + gc.game.board.getSquare('a2').piece = null; + gc.game.board.getSquare('a7').piece = Piece.createPawn(SideType.White); + gc.game.board.getSquare('a7').piece.moveCount = 1; + + gc.getStatus(true); + const m = gc.move('a7', 'a8', 'Q'); + + assert.strictEqual(m.move.postSquare.piece.type, PieceType.Queen); + assert.strictEqual(promoteEvents.length, 1); + assert.strictEqual(gc.game.moveHistory[0].promotion, true); + }); }); diff --git a/test/src/uciGameClient.js b/test/src/uciGameClient.js index c7e620e..0c4d97c 100644 --- a/test/src/uciGameClient.js +++ b/test/src/uciGameClient.js @@ -85,4 +85,20 @@ describe('UCIGameClient', () => { assert.throws(() => gc.move('e9e4')); assert.throws(() => gc.move('abcd')); }); + + it('should expose capture history via getCaptureHistory()', () => { + const gc = UCIGameClient.create(); + + gc.move('e2e4'); + gc.move('d7d5'); + const cap = gc.move('e4d5'); + + const h1 = gc.getCaptureHistory(); + assert.strictEqual(h1.length, 1); + assert.strictEqual(h1[0].type, PieceType.Pawn); + + cap.undo(); + const h2 = gc.getCaptureHistory(); + assert.strictEqual(h2.length, 0); + }); }); From 0c2dcd4b60f121ccc7d74beb91c68d5e5bd98ee8 Mon Sep 17 00:00:00 2001 From: Joshua Thomas Date: Thu, 11 Sep 2025 17:43:56 -0700 Subject: [PATCH 2/3] adding details to the readme for captured pieces --- README.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/README.md b/README.md index 72af278..6f1c29a 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,30 @@ uci.move('e7e5'); // black // uci.move('a7a8q'); ``` +### Capture History + +Each game client exposes a simple way to retrieve captured pieces in order of capture. + +```javascript +import chess from 'chess'; + +// Works with any client: create(), createSimple(), or createUCI() +const gc = chess.create(); + +gc.move('e4'); +gc.move('d5'); +const capture = gc.move('exd5'); + +// Retrieve captured pieces (latest at the end) +const captured = gc.getCaptureHistory(); +console.log(captured.length); // 1 +console.log(captured[0].type); // 'pawn' + +// Undo also rolls back capture history +capture.undo(); +console.log(gc.getCaptureHistory().length); // 0 +``` + ### Game Events The game client (both algebraic, simple) emit a number of events when scenarios occur on the board over the course of a match. From 641cd354d9958eca821f33d32e5b06a0e23f43f3 Mon Sep 17 00:00:00 2001 From: Joshua Thomas Date: Thu, 11 Sep 2025 18:15:21 -0700 Subject: [PATCH 3/3] fix: correct NotatedMove type declaration --- @types/chess/chess.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/@types/chess/chess.d.ts b/@types/chess/chess.d.ts index d1b9633..7d1e15c 100644 --- a/@types/chess/chess.d.ts +++ b/@types/chess/chess.d.ts @@ -121,7 +121,7 @@ declare namespace Chess { } interface NotatedMove { - dest: Squarethis.captureHistory = []; + dest: Square src: Square }