From 52026293947d5bcaea3d2e6875489d53f4feb6a8 Mon Sep 17 00:00:00 2001 From: Joshua Thomas Date: Thu, 11 Sep 2025 16:10:50 -0700 Subject: [PATCH 1/2] added support for UCI - addressing #78 --- README.md | 25 ++++ package.json | 2 +- src/main.js | 5 +- src/simpleGameClient.js | 2 +- src/uciGameClient.js | 238 ++++++++++++++++++++++++++++++++++++++ test/src/uciGameClient.js | 88 ++++++++++++++ 6 files changed, 357 insertions(+), 3 deletions(-) create mode 100644 src/uciGameClient.js create mode 100644 test/src/uciGameClient.js diff --git a/README.md b/README.md index ebf930f..72af278 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. diff --git a/package.json b/package.json index 5f1d256..8f36504 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.3.1", + "version": "1.4.0", "contributors": [ { "name": "Joshua Thomas", diff --git a/src/main.js b/src/main.js index 891fb53..d35f845 100644 --- a/src/main.js +++ b/src/main.js @@ -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 -}; \ No newline at end of file +}; diff --git a/src/simpleGameClient.js b/src/simpleGameClient.js index e446aba..4d4f65a 100644 --- a/src/simpleGameClient.js +++ b/src/simpleGameClient.js @@ -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 = []; diff --git a/src/uciGameClient.js b/src/uciGameClient.js new file mode 100644 index 0000000..b7f09cc --- /dev/null +++ b/src/uciGameClient.js @@ -0,0 +1,238 @@ +/* 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 isMoveValid(src, dest, validMoves) { + let + i = 0, + matches = (expr, sq) => ( + (typeof expr === 'string' && (sq.file + sq.rank) === expr) + || (expr && expr.file && expr.rank && + sq.file === expr.file && sq.rank === expr.rank) + ), + squares = []; + + for (i = 0; i < validMoves.length; i++) { + if (matches(src, validMoves[i].src)) { + squares = validMoves[i].squares; + } + } + + if (squares && squares.length > 0) { + for (i = 0; i < squares.length; i++) { + if (matches(dest, squares[i])) { + return true; + } + } + } + + return false; +} + +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 + dest = null, + move = null, + parsed = parseUCI(uci), + promo = null, + side = null, + src = null; + + if (!parsed) { + throw new Error(`UCI is invalid (${uci})`); + } + + // destructure the parsed UCI move + ({ src, dest, promo } = parsed); + + // determine the current side + side = this.game.getCurrentSide(); + + // ensure the move is valid + if (!isMoveValid(src, dest, this.validMoves)) { + throw new Error(`Move is invalid (${src.file}${src.rank} to ${dest.file}${dest.rank})`); + } + + // make the move + move = this.game.board.move(`${src.file}${src.rank}`, `${dest.file}${dest.rank}`); + if (move) { + // apply pawn promotion if applicable + 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 }; diff --git a/test/src/uciGameClient.js b/test/src/uciGameClient.js new file mode 100644 index 0000000..c7e620e --- /dev/null +++ b/test/src/uciGameClient.js @@ -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')); + }); +}); From 3b9392ac06898b5de835435623e04e63d1172f73 Mon Sep 17 00:00:00 2001 From: Joshua Thomas Date: Thu, 11 Sep 2025 16:57:49 -0700 Subject: [PATCH 2/2] addressing code review comment --- src/uciGameClient.js | 58 +++++++++++++++++++++----------------------- 1 file changed, 27 insertions(+), 31 deletions(-) diff --git a/src/uciGameClient.js b/src/uciGameClient.js index b7f09cc..b818eea 100644 --- a/src/uciGameClient.js +++ b/src/uciGameClient.js @@ -6,32 +6,6 @@ import { Piece } from './piece.js'; import { PieceType } from './piece.js'; // private helpers -function isMoveValid(src, dest, validMoves) { - let - i = 0, - matches = (expr, sq) => ( - (typeof expr === 'string' && (sq.file + sq.rank) === expr) - || (expr && expr.file && expr.rank && - sq.file === expr.file && sq.rank === expr.rank) - ), - squares = []; - - for (i = 0; i < validMoves.length; i++) { - if (matches(src, validMoves[i].src)) { - squares = validMoves[i].squares; - } - } - - if (squares && squares.length > 0) { - for (i = 0; i < squares.length; i++) { - if (matches(dest, squares[i])) { - return true; - } - } - } - - return false; -} function parseUCI(uci) { if (typeof uci !== 'string') { @@ -175,12 +149,15 @@ export class UCIGameClient extends EventEmitter { move(uci) { let + canonical = null, dest = null, move = null, parsed = parseUCI(uci), promo = null, + requiresPromotion = false, side = null, - src = null; + src = null, + srcSquare = null; if (!parsed) { throw new Error(`UCI is invalid (${uci})`); @@ -189,18 +166,37 @@ export class UCIGameClient extends EventEmitter { // 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(); - // ensure the move is valid - if (!isMoveValid(src, dest, this.validMoves)) { - throw new Error(`Move is invalid (${src.file}${src.rank} to ${dest.file}${dest.rank})`); + // 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 + // apply pawn promotion if applicable (already validated above) if (promo) { let piece; switch (promo) {