Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions @types/chess/chess.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -27,6 +28,11 @@ declare namespace Chess {
notatedMoves: Record<string, NotatedMove>
}

interface UCIGameStatus extends SimpleGameStatus {
/** Hash of next possible moves with key as UCI string and value as src-dest mapping */
uciMoves: Record<string, NotatedMove>
}

interface GameClient extends GameStatus {
/** The Game object, which includes the board and move history. */
game: Game
Expand All @@ -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 {
Expand All @@ -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'
Expand All @@ -75,6 +95,7 @@ declare namespace Chess {

interface Game {
board: ChessBoard
captureHistory: Piece[]
moveHistory: Move[]
}

Expand Down
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
4 changes: 4 additions & 0 deletions src/algebraicGameClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
26 changes: 23 additions & 3 deletions src/game.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export class Game extends EventEmitter {
super();

this.board = board;
this.captureHistory = [];
this.moveHistory = [];
}

Expand All @@ -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;
}
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/simpleGameClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
4 changes: 4 additions & 0 deletions src/uciGameClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,10 @@ export class UCIGameClient extends EventEmitter {
};
}

getCaptureHistory() {
return this.game.captureHistory;
}

move(uci) {
let
canonical = null,
Expand Down
17 changes: 17 additions & 0 deletions test/src/algebraicGameClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
64 changes: 64 additions & 0 deletions test/src/game.js
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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');
}
});
});
8 changes: 6 additions & 2 deletions test/src/main.js
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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');
});
});
71 changes: 71 additions & 0 deletions test/src/simpleGameClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
16 changes: 16 additions & 0 deletions test/src/uciGameClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading