pgn-manager
Version:
Libraray built on top of chess.js and pgn-parser to load and process PGN files in typescript.
412 lines (411 loc) • 15.6 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.FEN_EMPTY_POSITION = exports.FEN_START_POSITION = void 0;
const ChessJS = require("chess.js");
const Chess = typeof ChessJS === "function" ? ChessJS : ChessJS.Chess;
const pgnParser = require("pgn-parser");
const utils_1 = require("./utils");
exports.FEN_START_POSITION = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1";
exports.FEN_EMPTY_POSITION = "8/8/8/8/8/8/8/8";
class PGNManager {
/** The raw PGN string input */
rawPGN;
/** The parsed PGN game object */
game;
/** Array of moves in traversal order */
sortedMoves;
/** Map of moves to their FEN position strings */
moveFen;
/** Map of FEN position string to their move object */
fenMove = new Map();
/** Map of moves to their parent variations (or null for mainline) */
moveParent;
/** Map of variations to their parent moves */
ravParent;
/** Map of moves to the color of the player who made the move */
moveColor;
/**
* Creates a new PGNManager instance
* @param pgn - The PGN string to parse and manage
*/
constructor(pgn) {
this.rawPGN = pgn;
this.game = pgnParser.parse(pgn + " *")[0];
this.sortedMoves = [];
this.moveParent = new Map();
this.ravParent = new Map();
this.moveFen = new Map();
this.fenMove = new Map();
this.moveColor = new Map();
this.dfOnGame(this.game);
}
/**
* Initializes the game traversal starting from the initial position
* @param game - The parsed PGN game object
*/
dfOnGame = (game) => {
this.sortedMoves = [];
var chessGame = new Chess(game.headers?.find((h) => h.name.toUpperCase() === "FEN")?.value ||
exports.FEN_START_POSITION);
for (let move of game.moves) {
this.dfsOnGame(move, game, chessGame);
}
};
/**
* Performs depth-first traversal of the game moves and variations
* @param move - The current move being processed
* @param parent - The parent RAV (variation) containing the move
* @param chessGame - The chess instance for the current position
*/
dfsOnGame = (move, parent, chessGame) => {
this.sortedMoves.push(move);
this.moveParent.set(move, parent);
if (move.ravs) {
for (let rav of move.ravs) {
this.ravParent.set(rav, move);
let newVarChessGame = new Chess(chessGame.fen());
for (let ravMove of rav.moves) {
this.dfsOnGame(ravMove, rav, newVarChessGame);
}
}
}
// move only after variations are processed
if (!chessGame.move(move.move, { sloppy: false }) &&
!chessGame.move(move.move, { sloppy: true }))
console.log("Invalid move: " + move.move);
this.moveFen.set(move, chessGame.fen());
this.fenMove.set(chessGame.fen(), move);
this.moveColor.set(move, chessGame.turn() === "w" ? "b" : "w");
};
/**
* Gets the raw PGN string
* @returns The original PGN string
*/
get pgn() {
return this.rawPGN;
}
/**
* Gets the parsed PGN object
* @returns The parsed PGN game object
*/
get parsedPGN() {
return this.game;
}
/**
* Gets the game headers
* @returns Array of game headers
*/
get headers() {
if (!this.game || !this.game.headers)
return [];
return this.game.headers;
}
/**
* Gets a move by its number in the sequence
* @param moveNumber - The 1-based index of the move
* @returns The move object at the specified position
*/
getMove = (moveNumber) => {
let move = this.sortedMoves[moveNumber - 1];
return move;
};
/**
* Gets the number of a move in the sequence
* @param move - The move object
* @returns The 1-based index of the move
*/
getMoveNumber = (move) => {
return this.sortedMoves.indexOf(move) + 1;
};
/**
* Gets the next move in the sequence
* @param moveOrId - The current move object or move number
* @returns The next move in the sequence
* @throws Error if there are no moves in the game
*/
nextMove = (moveOrId) => {
let move;
if (typeof moveOrId === "number") {
move = this.getMove(moveOrId);
}
else {
move = moveOrId;
}
if (!move) {
if (this.sortedMoves.length == 0) {
throw Error("No moves in game");
}
return this.sortedMoves[0];
}
if (move == this.game.moves[this.game.moves.length - 1] ||
move === this.sortedMoves[this.sortedMoves.length - 1]) {
return move;
}
let parentRav = this.moveParent.get(move);
let tempNextMove = this.getMove(this.getMoveNumber(move) + 1);
if (parentRav) {
let indexInParent = parentRav.moves.indexOf(move);
if (indexInParent == parentRav.moves.length - 1) {
let superParent = this.ravParent.get(parentRav);
if (superParent) {
tempNextMove = this.nextMove(superParent);
}
}
else {
tempNextMove = parentRav.moves[indexInParent + 1];
}
}
return tempNextMove;
};
/**
* Checks if there is a next move available
* @param moveOrId - The current move object or move number
* @returns True if there is a next move, false otherwise
*/
hasNextMove = (moveOrId) => {
const move = typeof moveOrId === "number" ? this.getMove(moveOrId) : moveOrId;
return !move || this.nextMove(move) !== move;
};
/**
* Gets the previous move in the sequence
* @param moveOrId - The current move object or move number
* @returns The previous move or undefined if at the start
* @throws Error if there are no moves or if the move parameter is invalid
*/
previousMove = (moveOrId) => {
const move = typeof moveOrId === "number" ? this.getMove(moveOrId) : moveOrId;
if (!move) {
if (this.sortedMoves.length == 0) {
throw Error("No moves in game");
}
throw Error("Invalid 'move' parameter while getting previous move");
}
let currentMoveNumber = this.getMoveNumber(move);
if (currentMoveNumber == 0) {
return undefined;
}
let parentRav = this.moveParent.get(move);
let tempPrevMove = this.getMove(currentMoveNumber - 1);
if (parentRav) {
let indexInParent = parentRav.moves.indexOf(move);
if (indexInParent == 0) {
let superParent = this.ravParent.get(parentRav);
if (superParent) {
tempPrevMove = superParent;
}
}
else {
tempPrevMove = parentRav.moves[indexInParent - 1];
}
}
return tempPrevMove;
};
/**
* Gets the first move in the game
* @returns The first move
* @throws Error if there are no moves in the game
*/
getFirstMove = () => {
if (this.sortedMoves.length == 0) {
throw Error("No moves in game");
}
return this.sortedMoves[0];
};
/**
* Gets the last move in the game
* @returns The last move
* @throws Error if there are no moves in the game
*/
getLastMove = () => {
if (this.sortedMoves.length == 0) {
throw Error("No moves in game");
}
return this.game.moves[this.game.moves.length - 1];
};
/**
* Gets the FEN string for a specific move
* @param moveOrId - The move object or move number
* @returns The FEN string representing the position after the move
* @throws Error if the move parameter is invalid
*/
getMoveFen = (moveOrId) => {
const move = typeof moveOrId === "number" ? this.getMove(moveOrId) : moveOrId;
if (!move || !this.moveFen.has(move)) {
throw Error("Invalid 'move' parameter while getting fen");
}
let moveFen = this.moveFen.get(move);
return moveFen ? moveFen : exports.FEN_EMPTY_POSITION;
};
/**
* Gets the parent RAV (variation) for a move
* @param moveOrId - The move object or move number
* @returns The parent RAV or null if the move is in the main line
* @throws Error if the move parameter is invalid
*/
getParentRav = (moveOrId) => {
const move = typeof moveOrId === "number" ? this.getMove(moveOrId) : moveOrId;
if (!move || !this.moveParent.has(move)) {
throw Error("Invalid 'move' parameter while getting parent rav");
}
let parentRav = this.moveParent.get(move);
return parentRav ? parentRav : null;
};
/**
* Gets the color of the player who made the move
* @param moveOrId - The move object or move ID number
* @returns "w" for white or "b" for black
* @throws Error if the move parameter is invalid
*/
getMoveColor = (moveOrId) => {
const move = typeof moveOrId === "number" ? this.getMove(moveOrId) : moveOrId;
if (!move || !this.moveColor.has(move)) {
throw Error("Invalid 'move' parameter while getting move color");
}
return this.moveColor.get(move);
};
/***
* Pushes a new move into the game
* @param moveId - The ID of the move to push
* @param newMove - The move object to add
* @param result - The result of the game after this move (default is "*")
* @returns The newly created move object
* @throws Error if the move parameter is invalid
*/
pushMove = (moveId, newMove, result = "*") => {
let chess;
let parentRav;
let current = null;
// 1) Initialize Chess and parent variation
if (moveId === 0) {
parentRav = this.parsedPGN;
const fenHdr = this.headers.find((h) => h.name.toLowerCase() === "fen");
const startFen = fenHdr ? fenHdr.value : exports.FEN_START_POSITION;
chess = new Chess(startFen);
}
else {
current = this.getMove(moveId);
if (!current)
throw new Error("Invalid moveId while pushing a new move!");
parentRav = this.moveParent.get(current) || this.parsedPGN;
chess = new Chess(this.getMoveFen(current));
}
// 2) Play the SAN move (strict → sloppy)
const played = chess.move(newMove, { sloppy: false }) ||
chess.move(newMove, { sloppy: true });
if (!played)
throw new Error("Invalid move");
const san = chess.history().slice(-1)[0];
const nextToMove = chess.turn(); // 'w' or 'b'
// If the FEN already exists, we are trying to add a move that is already played
if (this.fenMove.has(chess.fen())) {
return this.fenMove.get(chess.fen());
}
// 3) Build move object with provisional move_number
const provisionalNumber = (() => {
if (!current) {
return 1; // brand‐new mainline
}
const currNum = current.move_number || this.previousMove(current)?.move_number;
const currColor = this.getMoveColor(current);
return currColor === "w" ? currNum : currNum + 1;
})();
const moveObj = {
move: san,
ravs: undefined,
move_number: provisionalNumber,
comments: [],
};
// 4) Insert into structure & flag first‐of‐variation
let isFirstOfVariation = false;
if (!current) {
// brand-new mainline => variation of first move if exists
const first = parentRav.moves[0];
if (first) {
isFirstOfVariation = true;
const newRav = { moves: [moveObj], result };
first.ravs = first.ravs || [];
first.ravs.push(newRav);
this.moveParent.set(moveObj, newRav);
this.ravParent.set(newRav, first);
}
else {
parentRav.moves.push(moveObj);
this.moveParent.set(moveObj, parentRav);
}
}
else {
// existing‐move branch
const lastInRav = parentRav.moves[parentRav.moves.length - 1];
const isContinuation = lastInRav === current;
if (!isContinuation) {
// new variation off the next move
isFirstOfVariation = true;
const anchor = this.nextMove(current) || current;
anchor.ravs = anchor.ravs || [];
const newRav = { moves: [moveObj], result };
anchor.ravs.push(newRav);
this.moveParent.set(moveObj, newRav);
this.ravParent.set(newRav, anchor);
}
else {
// continuation
parentRav.moves.push(moveObj);
this.moveParent.set(moveObj, parentRav);
}
}
// 5) Conditionally remove move_number
// Keep it only if first‐of‐variation OR new move is White to play
if (!(isFirstOfVariation || nextToMove === "b")) {
delete moveObj.move_number;
}
// 6) Final bookkeeping
this.moveFen.set(moveObj, chess.fen());
this.fenMove.set(chess.fen(), moveObj);
this.moveColor.set(moveObj, nextToMove === "w" ? "b" : "w");
this.rawPGN = (0, utils_1.regeneratePGN)(this.game, this.moveColor);
this.dfOnGame(this.game);
return moveObj;
};
/**
* Delete a move and all subsequent moves in its variation from the game
* @param moveId - The ID of the move to delete from
* @throws Error if the move parameter is invalid
*/
deleteMove = (moveId) => {
const move = this.getMove(moveId);
if (!move) {
throw Error("Invalid move");
}
// delete the move and subsequent moves from the game
const parentRav = this.getParentRav(move);
const index = parentRav.moves.indexOf(move);
const deletedMoves = parentRav.moves.splice(index);
// collect all moves and variations that need cleanup
const movsToClean = [];
const ravsToClean = [];
for (const deletedMove of deletedMoves) {
movsToClean.push(deletedMove);
if (deletedMove.ravs) {
for (const rav of deletedMove.ravs) {
ravsToClean.push(rav);
}
}
}
// Sort moves by their moveId in decreasing order
movsToClean.sort((a, b) => this.getMoveNumber(b) - this.getMoveNumber(a));
// clean up all maps at once
movsToClean.forEach((move) => {
this.moveParent.delete(move);
this.fenMove.delete(this.getMoveFen(move));
this.moveFen.delete(move);
this.moveColor.delete(move);
});
ravsToClean.forEach((rav) => {
this.ravParent.delete(rav);
});
// update the PGN and rebuild internal state
this.rawPGN = (0, utils_1.regeneratePGN)(this.game, this.moveColor);
this.dfOnGame(this.game);
};
}
exports.default = PGNManager;