Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Grammar based moves parsing #80

Closed
wants to merge 25 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
337ed08
feat: add nearley and move grammar
everyonesdesign Feb 1, 2025
961c902
feat: move grammar files
everyonesdesign Feb 1, 2025
c19b2e2
feat: add nearley ts types
everyonesdesign Feb 1, 2025
8e51273
feat: add nearley grammar annotations
everyonesdesign Feb 2, 2025
522832b
feat: move castling into separate move types
everyonesdesign Feb 2, 2025
a390d8d
feat: map all the move types with nearley
everyonesdesign Feb 2, 2025
93ddf23
feat: make tests run
everyonesdesign Feb 2, 2025
1dd715c
feat: fix some tests
everyonesdesign Feb 2, 2025
e9ad820
feat: fix algebraic promotion output
everyonesdesign Feb 2, 2025
54b44db
feat: omit algebraic promotion data if not needed
everyonesdesign Feb 2, 2025
b40d684
feat: filter out unique parsed moves only
everyonesdesign Feb 2, 2025
05700f4
feat: allow check or mate notation
everyonesdesign Feb 2, 2025
120270d
feat: allow en passant
everyonesdesign Feb 2, 2025
dbaf257
feat: fix pawn promotion
everyonesdesign Feb 2, 2025
302fc17
feat: fix promotionPiece field
everyonesdesign Feb 2, 2025
13ea040
feat: fix uci promotion optional field
everyonesdesign Feb 2, 2025
b8ee690
feat: exclude pawn moves for the same to/from file
everyonesdesign Feb 2, 2025
a986451
feat: fix one more ambiguity case
everyonesdesign Feb 2, 2025
24c9b6a
feat: fix last complex case
everyonesdesign Feb 2, 2025
2c22f29
feat: remove console.log calls
everyonesdesign Feb 2, 2025
d3569a7
refactor: remove unused file app/grammar/move-grammar.js
everyonesdesign Feb 2, 2025
d07fd39
feat: disable uppercase file notation
everyonesdesign Feb 2, 2025
a325be1
feat: pick most specific moves out of potential list
everyonesdesign Feb 2, 2025
09054e3
feat: remove en passant grammar
everyonesdesign Feb 2, 2025
63115f6
test: provide tests for parsing time measurement
everyonesdesign Feb 2, 2025
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
192 changes: 68 additions & 124 deletions app/src/chess.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import nearley from 'nearley';
import filter from 'lodash/filter';
import isEqual from 'lodash/isEqual';
import find from 'lodash/find';
import uniqWith from 'lodash/uniqWith';
import {
postMessage,
squareToCoords,
Expand All @@ -16,14 +17,13 @@ import {
TArea,
TPiece,
IMoveTemplate,
IPotentialMoves,
IMove,
TFromTo,
TMoveType,
Nullable,
} from './types';
import { i18n } from './i18n';

import grammar, { Move as ParsedMove } from './grammar/move.cjs';

/**
* Check if input is valid square name
*/
Expand Down Expand Up @@ -137,7 +137,7 @@ export function makeMove(
/**
* Get exact from and to coords from move data
*/
export function getLegalMoves(board: IChessboard, potentialMoves: IPotentialMoves) : IMove[] {
export function getLegalMoves(board: IChessboard, potentialMoves: IMoveTemplate[]) : IMove[] {
if (!board || !potentialMoves.length || !board.isPlayersMove()) {
return [];
}
Expand Down Expand Up @@ -176,7 +176,7 @@ export function getLegalMoves(board: IChessboard, potentialMoves: IPotentialMove
];
});

return excludeConflictingMoves(legalMoves);
return excludeConflictingMoves(pickMostSpecificMoves(legalMoves));
}

/**
Expand All @@ -197,136 +197,80 @@ export function excludeConflictingMoves(moves: IMove[]) : IMove[] {
}

/**
* Parse message input by user
* Sometimes returned moves are essentially the same or similar
* This method omits less specific moves
* Example1:
* Input: [{ piece: '.', from: 'b4', to: 'b5' }, { piece: 'p', from: 'b4', to: 'b5' }]
* Output: [{ piece: 'p', from: 'b4', to: 'b5' }]
*/
export function parseMoveInput(input: string): IPotentialMoves {
return [
...parseUCI(input),
...parseAlgebraic(input),
];
function pickMostSpecificMoves(moves: IMove[]) : IMove[] {
const result: IMove[] = [];
const movesDict: Record<string, IMove> = {};
moves.forEach(move => {
if (!movesDict[move.from + move.to]) {
movesDict[move.from + move.to] = move;
} else {
// Override with the most specific piece
if (movesDict[move.from + move.to].piece === '.' && move.piece !== '.') {
movesDict[move.from + move.to] = move;
}
}
});
return Object.values(movesDict);
}

/**
* Parse simplest move format: 'e2e4'
* Parse message input by user
*/
export function parseUCI(input: string) : IPotentialMoves {
const filteredSymbols = input.replace(/( |-)+/g, '');
const fromSquare = <TArea>filteredSymbols.slice(0, 2);
const toSquare = <TArea>filteredSymbols.slice(2, 4);
const promotion = <TPiece>filteredSymbols.slice(4, 5);

if (validateSquareName(fromSquare) && validateSquareName(toSquare)) {
const result: IMoveTemplate = {
piece: '.',
from: fromSquare,
to: toSquare,
};

if (promotion) {
result.promotionPiece = promotion;
}

return [result];
export function parseMoveInput(input: string): IMoveTemplate[] {
const parser = new nearley.Parser(nearley.Grammar.fromCompiled(grammar));

try {
parser.feed(input);
const parsed = uniqWith(parser.results, isEqual) as ParsedMove[]; // Cast to expected type
const results: IMoveTemplate[] = [];
parsed.forEach(p => {
const movesTemplates = mapParseResult(p);
movesTemplates.forEach(mt => results.push(mt));
});
return results;
} catch (error) {
// Do nothing
}

return [];
}

/**
* Extract all possible information from algebraic notation
*/
export function parseAlgebraic(input: string): IPotentialMoves {
// ignore UCI notation
if (/^\s*[a-h][1-8][a-h][1-8][rqknb]?\s*$/.test(input)) {
return [];
}

let moveString = input.replace(/[\s\-\(\)]+/g, '');
const moves: IPotentialMoves = [];

if (/[o0][o0][o0]/i.test(moveString)) {
return [
// white long castling
{
piece: 'k',
from: 'e1',
to: 'c1',
},
// black long castling
{
piece: 'k',
from: 'e8',
to: 'c8',
}
];
} else if (/[o0][o0]/i.test(moveString)) {
return [
// white short castling
{
piece: 'k',
from: 'e1',
to: 'g1',
},
// black short castling
{
piece: 'k',
from: 'e8',
to: 'g8',
}
];
}


const pawnRegex = /^([a-h])?(x)?([a-h])([1-8])(e\.?p\.?)?(=[qrnbQRNB])?[+#]?$/;
const pawnResult = moveString.match(pawnRegex);
if (pawnResult) {
const [
_,
fromFile,
isCapture,
toFile,
toRank,
enPassant,
promotion,
] = pawnResult;

if (fromFile === toFile) {
// Do nothing
// This disables moves like `bb4` for pawns to avoid ambiguity with bishops
} else {
const move: IMoveTemplate = {
piece: 'p',
from: <TArea>`${fromFile || '.'}.`,
to: <TArea>`${toFile || '.'}${toRank || '.'}`,
};

if (promotion) {
move.promotionPiece = <TPiece>promotion[1].toLowerCase();
function mapParseResult(move: ParsedMove): IMoveTemplate[] {
if (move.type === 'castling') {
if (move.castlingData.kind === 'long') {
return [
{ piece: 'k', from: 'e1', to: 'c1' }, // white long castling
{ piece: 'k', from: 'e8', to: 'c8' } // black long castling
];
} else if (move.castlingData.kind === 'short') {
return [
{ piece: 'k', from: 'e1', to: 'g1' }, // white short castling
{ piece: 'k', from: 'e8', to: 'g8' } // black short castling
];
}
} else if (move.type === 'uci') {
return [move.uciData];
} else if (move.type === 'algebraic') {
if (move.algebraicData.type === 'pawn') {
const fromFile = move.algebraicData.pawnData.from[0];
const toFile = move.algebraicData.pawnData.to[0];
if (fromFile === toFile) {
// Do nothing
// This disables moves like `bb4` for pawns to avoid ambiguity with bishops
return [];
} else {
return [move.algebraicData.pawnData];
}

moves.push(move);
} else if (move.algebraicData.type === 'piece') {
return [move.algebraicData.pieceData];
}
}

const pieceRegex = /^([RQKNBrqknb])([a-h])?([1-8])?(x)?([a-h])([1-8])?[+#]?$/;
const pieceResult = moveString.match(pieceRegex);
if (pieceResult) {
const [
_,
pieceName,
fromFile,
fromVer,
isCapture,
toFile,
toRank,
] = pieceResult;

moves.push({
piece: <TPiece>(pieceName).toLowerCase(),
from: <TArea>`${fromFile || '.'}${fromVer || '.'}`,
to: <TArea>`${toFile || '.'}${toRank || '.'}`,
});
}

return moves;
return [];
}
94 changes: 94 additions & 0 deletions app/src/grammar/move.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// Generated automatically by nearley, version 2.20.1
// http://github.com/Hardmath123/nearley
(function () {
function id(x) { return x[0]; }
var grammar = {
Lexer: undefined,
ParserRules: [
{"name": "Move", "symbols": ["UciMove"], "postprocess": (data) => ({ type: "uci", uciData: data[0] })},
{"name": "Move", "symbols": ["AlgebraicMove"], "postprocess": (data) => ({ type: "algebraic", algebraicData: data[0] })},
{"name": "Move", "symbols": ["CastlingMove"], "postprocess": (data) => ({ type: "castling", castlingData: data[0] })},
{"name": "UciMove", "symbols": ["UciCoord", "UciCoord", "UciPromotion"], "postprocess": (data) => {
const result = { piece: ".", from: data[0], to: data[1] };
if (data[2]) {
result.promotionPiece = data[2]
}
return result;
} },
{"name": "UciCoord", "symbols": ["File", "Rank"], "postprocess": (data) => data[0] + data[1]},
{"name": "UciPromotion", "symbols": ["NotKingPiece"], "postprocess": (d) => d[0]},
{"name": "UciPromotion", "symbols": [], "postprocess": () => undefined},
{"name": "AlgebraicMove", "symbols": ["PieceMove", "CheckOrMate"], "postprocess": (data) => ({ type: "piece", pieceData: data[0] })},
{"name": "AlgebraicMove", "symbols": ["PawnMove", "CheckOrMate"], "postprocess": (data) => ({ type: "pawn", pawnData: data[0] })},
{"name": "PawnMove", "symbols": ["MaybeFile", "Capture", "File", "Rank", "Promotion"], "postprocess": (data) => {
const result = { piece: "p", from: data[0] + '.', to: data[2] + data[3] };
if (data[4]) {
result.promotionPiece = data[4]
}
return result;
} },
{"name": "PieceMove", "symbols": ["Piece", "MaybeFile", "MaybeRank", "Capture", "File", "Rank"], "postprocess": (data) => ({
piece: data[0],
from: data[1] + data[2],
to: data[4] + data[5],
}) },
{"name": "CastlingMove", "symbols": ["ShortCastling"], "postprocess": (data) => ({ kind: 'short' })},
{"name": "CastlingMove", "symbols": ["LongCastling"], "postprocess": (data) => ({ kind: 'long' })},
{"name": "LongCastling", "symbols": ["CastlingChar", "CastlingSeparator", "CastlingChar", "CastlingSeparator", "CastlingChar"]},
{"name": "ShortCastling", "symbols": ["CastlingChar", "CastlingSeparator", "CastlingChar"]},
{"name": "CastlingSeparator", "symbols": [{"literal":"-"}]},
{"name": "CastlingSeparator", "symbols": []},
{"name": "CastlingChar", "symbols": [{"literal":"o"}]},
{"name": "CastlingChar", "symbols": [{"literal":"O"}]},
{"name": "CastlingChar", "symbols": [{"literal":"0"}]},
{"name": "Capture", "symbols": [{"literal":"x"}]},
{"name": "Capture", "symbols": []},
{"name": "Promotion", "symbols": [{"literal":"="}, "NotKingPiece"], "postprocess": (data) => data[1]},
{"name": "Promotion", "symbols": [], "postprocess": () => undefined},
{"name": "Piece", "symbols": ["NotKingPiece"], "postprocess": (d) => d[0]},
{"name": "Piece", "symbols": ["KingPiece"], "postprocess": (d) => d[0]},
{"name": "NotKingPiece", "symbols": [{"literal":"r"}], "postprocess": () => "r"},
{"name": "NotKingPiece", "symbols": [{"literal":"n"}], "postprocess": () => "n"},
{"name": "NotKingPiece", "symbols": [{"literal":"b"}], "postprocess": () => "b"},
{"name": "NotKingPiece", "symbols": [{"literal":"q"}], "postprocess": () => "q"},
{"name": "NotKingPiece", "symbols": [{"literal":"R"}], "postprocess": () => "r"},
{"name": "NotKingPiece", "symbols": [{"literal":"N"}], "postprocess": () => "n"},
{"name": "NotKingPiece", "symbols": [{"literal":"B"}], "postprocess": () => "b"},
{"name": "NotKingPiece", "symbols": [{"literal":"Q"}], "postprocess": () => "q"},
{"name": "CheckOrMate", "symbols": ["Check"]},
{"name": "CheckOrMate", "symbols": ["Mate"]},
{"name": "Check", "symbols": [{"literal":"+"}]},
{"name": "Check", "symbols": []},
{"name": "Mate", "symbols": [{"literal":"#"}]},
{"name": "Mate", "symbols": []},
{"name": "KingPiece", "symbols": [{"literal":"K"}], "postprocess": () => "k"},
{"name": "KingPiece", "symbols": [{"literal":"k"}], "postprocess": () => "k"},
{"name": "File", "symbols": [{"literal":"a"}], "postprocess": () => "a"},
{"name": "File", "symbols": [{"literal":"b"}], "postprocess": () => "b"},
{"name": "File", "symbols": [{"literal":"c"}], "postprocess": () => "c"},
{"name": "File", "symbols": [{"literal":"d"}], "postprocess": () => "d"},
{"name": "File", "symbols": [{"literal":"e"}], "postprocess": () => "e"},
{"name": "File", "symbols": [{"literal":"f"}], "postprocess": () => "f"},
{"name": "File", "symbols": [{"literal":"g"}], "postprocess": () => "g"},
{"name": "File", "symbols": [{"literal":"h"}], "postprocess": () => "h"},
{"name": "MaybeFile", "symbols": ["File"], "postprocess": (d) => d[0]},
{"name": "MaybeFile", "symbols": [], "postprocess": () => '.'},
{"name": "Rank", "symbols": [{"literal":"1"}]},
{"name": "Rank", "symbols": [{"literal":"2"}]},
{"name": "Rank", "symbols": [{"literal":"3"}]},
{"name": "Rank", "symbols": [{"literal":"4"}]},
{"name": "Rank", "symbols": [{"literal":"5"}]},
{"name": "Rank", "symbols": [{"literal":"6"}]},
{"name": "Rank", "symbols": [{"literal":"7"}]},
{"name": "Rank", "symbols": [{"literal":"8"}]},
{"name": "MaybeRank", "symbols": ["Rank"], "postprocess": (d) => d[0]},
{"name": "MaybeRank", "symbols": [], "postprocess": () => '.'}
]
, ParserStart: "Move"
}
if (typeof module !== 'undefined'&& typeof module.exports !== 'undefined') {
module.exports = grammar;
} else {
window.grammar = grammar;
}
})();
33 changes: 33 additions & 0 deletions app/src/grammar/move.cjs.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { CompiledRules } from 'nearley';
export type Move =
| { type: 'uci'; uciData: UciMove }
| { type: 'algebraic'; algebraicData: AlgebraicMove }
| { type: 'castling'; castlingData: CastlingMove };
export type UciMove = { piece: '.', from: UciCoord; to: UciCoord, promotionPiece?: Promotion };
export type UciCoord = string;
export type AlgebraicMove =
| { type: 'piece'; pieceData: PieceMove }
| { type: 'pawn'; pawnData: PawnMove };
export type PawnMove = {
piece: 'p';
from: string;
to: string;
promotionPiece?: Promotion;
};
export type PieceMove = {
piece: Piece;
from: string;
to: string;
};
export type CastlingMove = { kind: 'short' } | { kind: 'long' };
export type CastlingChar = string;
export type Capture = 'x';
export type Promotion = NotKingPiece;
export type Piece = NotKingPiece | KingPiece;
export type NotKingPiece = 'r' | 'n' | 'b' | 'q';
export type KingPiece = 'k';
export type File = 'a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'g' | 'h';
export type Rank = string;

declare const grammar:CompiledRules;
export default grammar;
Loading
Loading