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
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

* Accepts moves in algebraic notation
* Loads board position from FEN (Forsyth-Edwards Notation)
* Supports UCI (Universal Chess Interface) coordinate format
* Lists valid moves in algebraic notation
* Fuzzy algebraic notation parsing
* En Passant validation
Expand Down Expand Up @@ -102,6 +103,30 @@ move = gameClient.move('a4');
status = gameClient.getStatus();
```

### Universal Chess Interface (UCI) Game Client

The library also supports the Universal Chess Interface (UCI) coordinate format for moves and for listing valid moves.

```javascript
import chess from 'chess';

// Create a UCI-based game client
const uci = chess.createUCI();

// Inspect current status and valid UCI moves
let status = uci.getStatus();
// status.uciMoves is a map of all legal UCI moves from the position
console.log(Object.keys(status.uciMoves)); // e.g., [ 'e2e4', 'g1f3', ... ]

// Make UCI moves
uci.move('e2e4'); // white
uci.move('e7e5'); // black

// Promotions are encoded with a trailing piece letter: q, r, b, n
// For example, promote a pawn to a queen
// uci.move('a7a8q');
```

### 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.3.1",
"version": "1.4.0",
"contributors": [
{
"name": "Joshua Thomas",
Expand Down
5 changes: 4 additions & 1 deletion src/main.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { AlgebraicGameClient } from './algebraicGameClient.js';
import { SimpleGameClient } from './simpleGameClient.js';
import { UCIGameClient } from './uciGameClient.js';

export const create = (opts) => AlgebraicGameClient.create(opts);
export const createSimple = () => SimpleGameClient.create();
export const fromFEN = (fen, opts) => AlgebraicGameClient.fromFEN(fen, opts);
export const createUCI = () => UCIGameClient.create();

// exports
export default {
create,
createSimple,
createUCI,
fromFEN
};
};
2 changes: 1 addition & 1 deletion src/simpleGameClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ function isMoveValid (src, dest, validMoves) {
i = 0,
isFound = (expr, sq) => {
return ((typeof expr === 'string' && sq.file + sq.rank === expr) ||
(expr.rank && expr.file &&
(expr && expr.rank && expr.file &&
sq.file === expr.file && sq.rank === expr.rank));
},
squares = [];
Expand Down
234 changes: 234 additions & 0 deletions src/uciGameClient.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
/* eslint sort-imports: 0 */
import { EventEmitter } from 'events';
import { Game } from './game.js';
import { GameValidation } from './gameValidation.js';
import { Piece } from './piece.js';
import { PieceType } from './piece.js';

// private helpers

function parseUCI(uci) {
if (typeof uci !== 'string') {
return null;
}

// UCI format: e2e4, e7e8q (promotion), case-insensitive for promo
let
formatRegex = /^([a-h][1-8])([a-h][1-8])([qrbnQRBN])?$/,
uciMove = uci.trim().match(formatRegex);

if (!uciMove) {
return null;
}

let
dest = { file: uciMove[2][0], rank: Number(uciMove[2][1]) },
promo = uciMove[3] ? uciMove[3].toUpperCase() : '',
src = { file: uciMove[1][0], rank: Number(uciMove[1][1]) };

return { dest, promo, src };
}

function updateGameClient(gameClient) {
return gameClient.validation.start((err, result) => {
if (err) {
throw new Error(err);
}

gameClient.isCheck = result.isCheck;
gameClient.isCheckmate = result.isCheckmate;
gameClient.isRepetition = result.isRepetition;
gameClient.isStalemate = result.isStalemate;
gameClient.validMoves = result.validMoves;
gameClient.uciMoves = notateUCI(result.validMoves);
});
}

function notateUCI(validMoves) {
let
i = 0,
isPromotion = false,
notation = {};

// iterate through all valid moves and create UCI notation
for (; i < validMoves.length; i++) {
let
p = validMoves[i].src.piece,
src = validMoves[i].src;

// reset inner index for each piece's move list
for (let n = 0; n < validMoves[i].squares.length; n++) {
// get the destination square for this move
let sq = validMoves[i].squares[n];

// base notation
let base = `${src.file}${src.rank}${sq.file}${sq.rank}`;

// check for potential promotion
/* eslint no-magic-numbers: 0 */
isPromotion =
(sq.rank === 8 || sq.rank === 1) &&
p.type === PieceType.Pawn;

if (isPromotion) {
// add all promotion options
['q', 'r', 'b', 'n'].forEach((promo) => {
notation[`${base}${promo}`] = {
dest: sq,
src
};
});

continue
}

// regular move
notation[base] = {
dest: sq,
src
};
}
}

return notation;
}

export class UCIGameClient extends EventEmitter {
constructor(game) {
super();

this.game = game;
this.isCheck = false;
this.isCheckmate = false;
this.isRepetition = false;
this.isStalemate = false;
this.uciMoves = {};
this.validMoves = [];
this.validation = GameValidation.create(this.game);

// bubble the game and board events
['check', 'checkmate'].forEach((ev) => {
this.game.on(ev, (data) => this.emit(ev, data));
});

['capture', 'castle', 'enPassant', 'move', 'promote', 'undo'].forEach((ev) => {
this.game.board.on(ev, (data) => this.emit(ev, data));
});

const self = this;
this.on('undo', () => {
// force an update
self.getStatus(true);
});
}

static create() {
let
game = Game.create(),
gameClient = new UCIGameClient(game);

updateGameClient(gameClient);

return gameClient;
}

getStatus(forceUpdate) {
if (forceUpdate) {
updateGameClient(this);
}

return {
board: this.game.board,
isCheck: this.isCheck,
isCheckmate: this.isCheckmate,
isRepetition: this.isRepetition,
isStalemate: this.isStalemate,
uciMoves: this.uciMoves
};
}

move(uci) {
let
canonical = null,
dest = null,
move = null,
parsed = parseUCI(uci),
promo = null,
requiresPromotion = false,
side = null,
src = null,
srcSquare = null;

if (!parsed) {
throw new Error(`UCI is invalid (${uci})`);
}

// destructure the parsed UCI move
({ src, dest, promo } = parsed);

// normalize UCI key to compare with generated map
canonical = promo
? `${src.file}${src.rank}${dest.file}${dest.rank}${promo.toLowerCase()}`
: `${src.file}${src.rank}${dest.file}${dest.rank}`;

// ensure move exactly matches a generated UCI move
if (!this.uciMoves || !this.uciMoves[canonical]) {
throw new Error(`Move is invalid (${uci})`);
}

// determine the current side
side = this.game.getCurrentSide();

// additional safety: enforce promotion semantics
srcSquare = this.game.board.getSquare(src.file, src.rank);
requiresPromotion =
srcSquare && srcSquare.piece && srcSquare.piece.type === PieceType.Pawn &&
(dest.rank === 8 || dest.rank === 1);

if (requiresPromotion && !promo) {
throw new Error(`Promotion required for move (${uci})`);
}

if (promo && !requiresPromotion) {
throw new Error(`Promotion flag not allowed for move (${uci})`);
}

// make the move
move = this.game.board.move(`${src.file}${src.rank}`, `${dest.file}${dest.rank}`);
if (move) {
// apply pawn promotion if applicable (already validated above)
if (promo) {
let piece;
switch (promo) {
case 'B':
piece = Piece.createBishop(side);
break;
case 'N':
piece = Piece.createKnight(side);
break;
case 'Q':
piece = Piece.createQueen(side);
break;
case 'R':
piece = Piece.createRook(side);
break;
default:
piece = null;
break;
}

if (piece) {
this.game.board.promote(move.move.postSquare, piece);
}
}

updateGameClient(this);

return move;
}

throw new Error(`Move is invalid (${uci})`);
}
}

export default { UCIGameClient };
88 changes: 88 additions & 0 deletions test/src/uciGameClient.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/* eslint no-magic-numbers:0 */
import { assert, describe, it } from 'vitest';
import { PieceType, SideType, Piece } from '../../src/piece.js';
import { UCIGameClient } from '../../src/uciGameClient.js';

describe('UCIGameClient', () => {
it('should have proper status once board is created', () => {
const gc = UCIGameClient.create();
const s = gc.getStatus();

assert.strictEqual(s.isCheck, false);
assert.strictEqual(s.isCheckmate, false);
assert.strictEqual(s.isRepetition, false);
assert.strictEqual(s.isStalemate, false);
assert.strictEqual(Object.keys(s.uciMoves).length, 20);
});

it('should trigger move events on UCI moves', () => {
const gc = UCIGameClient.create();
const moveEvent = [];
gc.on('move', (ev) => moveEvent.push(ev));

gc.move('b2b4');
gc.move('e7e6');

assert.ok(moveEvent);
assert.strictEqual(moveEvent.length, 2);
});

it('should perform a pawn capture using UCI', () => {
const gc = UCIGameClient.create();

gc.move('e2e4');
gc.move('d7d5');
const r = gc.move('e4d5');

assert.strictEqual(r.move.capturedPiece.type, PieceType.Pawn);
});

it('should castle using UCI coordinates', () => {
const gc = UCIGameClient.create();
const castleEvent = [];
gc.on('castle', (ev) => castleEvent.push(ev));

// clear path for white long castle (e1c1)
gc.game.board.getSquare('b1').piece = null;
gc.game.board.getSquare('c1').piece = null;
gc.game.board.getSquare('d1').piece = null;

gc.getStatus(true);
gc.move('e1c1');

assert.ok(castleEvent);
assert.strictEqual(castleEvent.length, 1);
});

it('should handle pawn promotion via UCI and enumerate all promotion options', () => {
const gc = UCIGameClient.create();

// setup white pawn a7->a8=Q checkmate scenario
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;

const pre = gc.getStatus(true);
assert.isUndefined(pre.uciMoves['a7a8']);
assert.isDefined(pre.uciMoves['a7a8q']);
assert.isDefined(pre.uciMoves['a7a8r']);
assert.isDefined(pre.uciMoves['a7a8b']);
assert.isDefined(pre.uciMoves['a7a8n']);
const m = gc.move('a7a8q');
const r = gc.getStatus();

assert.strictEqual(m.move.postSquare.piece.type, PieceType.Queen);
assert.strictEqual(r.isCheckmate, true);
});

it('should throw on invalid UCI', () => {
const gc = UCIGameClient.create();
assert.throws(() => gc.move('e9e4'));
assert.throws(() => gc.move('abcd'));
});
});
Loading