UNPKG

kokopu

Version:

A JavaScript/TypeScript library implementing the chess game rules and providing tools to read/write the standard chess file formats.

1,026 lines (861 loc) 37.4 kB
/*! * -------------------------------------------------------------------------- * * * * Kokopu - A JavaScript/TypeScript chess library. * * <https://www.npmjs.com/package/kokopu> * * Copyright (C) 2018-2025 Yoann Le Montagner <yo35 -at- melix.net> * * * * Kokopu is free software: you can redistribute it and/or * * modify it under the terms of the GNU Lesser General Public License * * as published by the Free Software Foundation, either version 3 of * * the License, or (at your option) any later version. * * * * Kokopu is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU Lesser General Public License for more details. * * * * You should have received a copy of the GNU Lesser General * * Public License along with this program. If not, see * * <http://www.gnu.org/licenses/>. * * * * -------------------------------------------------------------------------- */ import { Color, GameResult, GameVariant } from './base_types'; import { DateValue } from './date_value'; import { IllegalArgument, InvalidPOJO } from './exception'; import { GamePOJO, PlayerPOJO } from './game_pojo'; import { isValidECO, nagSymbol, variantWithCanonicalStartPosition } from './helper'; import { i18n } from './i18n'; import { AbstractNode, Node, Variation } from './node_variation'; import { Position } from './position'; import { trimAndCollapseSpaces, isValidElo, isValidRound } from './private_game/common'; import { MoveTreeRoot } from './private_game/node_variation_impl'; import { POJOExceptionBuilder, decodeStringField, decodeNumberField, decodeObjectField } from './private_game/pojo_util'; import { ColorImpl, GameResultImpl, colorFromString, resultFromString, resultToString } from './private_position/base_types_impl'; const enum RoundPart { ROUND, SUB_ROUND, SUB_SUB_ROUND, } /** * Chess game, with the move history, the position at each step of the game, the comments and annotations (if any), * the result of the game, and some meta-data such as the name of the players, the date of the game, the name of the tournament, etc... */ export class Game { // Headers private _playerName: [ string | undefined, string | undefined ]; private _playerElo: [ number | undefined, number | undefined ]; private _playerTitle: [ string | undefined, string | undefined ]; private _event?: string; private _round: [ number | undefined, number | undefined, number | undefined ]; private _date?: DateValue; private _site?: string; private _annotator?: string; private _eco?: string; private _opening?: string; private _openingVariation?: string; private _openingSubVariation?: string; private _termination?: string; private _result: GameResultImpl; // Moves private _moveTreeRoot: MoveTreeRoot; constructor() { this._playerName = [ undefined, undefined ]; this._playerElo = [ undefined, undefined ]; this._playerTitle = [ undefined, undefined ]; this._round = [ undefined, undefined, undefined ]; this._result = GameResultImpl.LINE; this._moveTreeRoot = new MoveTreeRoot(); } /** * Clear all the headers (player names, elos, titles, event name, date, etc...). * * The {@link Game.result} header is reseted to its default value. * The initial position and moves are not modified. */ clearHeaders(): void { this._playerName = [ undefined, undefined ]; this._playerElo = [ undefined, undefined ]; this._playerTitle = [ undefined, undefined ]; this._event = undefined; this._round = [ undefined, undefined, undefined ]; this._date = undefined; this._site = undefined; this._annotator = undefined; this._eco = undefined; this._opening = undefined; this._openingVariation = undefined; this._openingSubVariation = undefined; this._termination = undefined; this._result = GameResultImpl.LINE; } /** * Get the name of the player corresponding to the given color. */ playerName(color: Color): string | undefined; /** * Set the name of the player corresponding to the given color. * * @param value - If `undefined`, the existing value (if any) is erased. */ playerName(color: Color, value: string | undefined): void; playerName(color: Color, value?: string | undefined) { const colorCode = colorFromString(color); if (colorCode < 0) { throw new IllegalArgument('Game.playerName()'); } if (arguments.length === 1) { return this._playerName[colorCode]; } else { this._playerName[colorCode] = sanitizeStringHeader(value); } } /** * Get the elo of the player corresponding to the given color. * * If defined, the returned value is guaranteed to be an integer >= 0. */ playerElo(color: Color): number | undefined; /** * Set the elo of the player corresponding to the given color. * * @param value - If `undefined`, the existing value (if any) is erased. Must be an integer >= 0. */ playerElo(color: Color, value: number | undefined): void; playerElo(color: Color, value?: number | undefined) { const colorCode = colorFromString(color); if (colorCode < 0) { throw new IllegalArgument('Game.playerElo()'); } if (arguments.length === 1) { return this._playerElo[colorCode]; } else { value = sanitizeNumberHeader(value); if (value === undefined || isValidElo(value)) { this._playerElo[colorCode] = value; } else { throw new IllegalArgument('Game.playerElo()'); } } } /** * Get the title of the player corresponding to the given color. */ playerTitle(color: Color): string | undefined; /** * Set the title of the player corresponding to the given color. * * @param value - If `undefined`, the existing value (if any) is erased. */ playerTitle(color: Color, value: string | undefined): void; playerTitle(color: Color, value?: string | undefined) { const colorCode = colorFromString(color); if (colorCode < 0) { throw new IllegalArgument('Game.playerTitle()'); } if (arguments.length === 1) { return this._playerTitle[colorCode]; } else { this._playerTitle[colorCode] = sanitizeStringHeader(value); } } /** * Get the event. */ event(): string | undefined; /** * Set the event. * * @param value - If `undefined`, the existing value (if any) is erased. */ event(value: string | undefined): void; event(value?: string | undefined) { if (arguments.length === 0) { return this._event; } else { this._event = sanitizeStringHeader(value); } } /** * Get the round. */ round(): number | undefined; /** * Set the round. * * @param value - If `undefined`, the existing value (if any) is erased. Must be an integer >= 0. */ round(value: number | undefined): void; round(value?: number | undefined) { if (arguments.length === 0) { return this._round[RoundPart.ROUND]; } else { this._setRoundPart(RoundPart.ROUND, value, 'Game.round()'); } } /** * Get the sub-round. */ subRound(): number | undefined; /** * Set the sub-round. * * @param value - If `undefined`, the existing value (if any) is erased. Must be an integer >= 0. */ subRound(value: number | undefined): void; subRound(value?: number | undefined) { if (arguments.length === 0) { return this._round[RoundPart.SUB_ROUND]; } else { this._setRoundPart(RoundPart.SUB_ROUND, value, 'Game.subRound()'); } } /** * Get the sub-sub-round. */ subSubRound(): number | undefined; /** * Set the sub-sub-round. * * @param value - If `undefined`, the existing value (if any) is erased. Must be an integer >= 0. */ subSubRound(value: number | undefined): void; subSubRound(value?: number | undefined) { if (arguments.length === 0) { return this._round[RoundPart.SUB_SUB_ROUND]; } else { this._setRoundPart(RoundPart.SUB_SUB_ROUND, value, 'Game.subSubRound()'); } } private _setRoundPart(roundPart: RoundPart, value: number | undefined, methodName: string) { value = sanitizeNumberHeader(value); if (value === undefined || isValidRound(value)) { this._round[roundPart] = value; } else { throw new IllegalArgument(methodName); } } /** * Get the round, sub-round and sub-sub-round as a human-readable string, the 3 components being separated by dot characters. */ fullRound(): string | undefined { return formatFullRound(this._round[RoundPart.ROUND], this._round[RoundPart.SUB_ROUND], this._round[RoundPart.SUB_SUB_ROUND], '?'); } /** * Get the date of the game. */ date(): DateValue | undefined; /** * Set the date of the game. * * @param value - If `undefined`, the existing value (if any) is erased. */ date(value: DateValue | Date | undefined): void; /** * Set the date of the game. * * If the month and/or the day of month are missing, the date of the game will be partially defined * (see {@link DateValue} for more details regarding partially defined dates). */ date(year: number, month?: number, day?: number): void; date(valueOrYear?: DateValue | Date | undefined | number, month?: number, day?: number) { switch (arguments.length) { case 0: return this._date; case 1: if (valueOrYear === undefined || valueOrYear === null) { this._date = undefined; } else if (valueOrYear instanceof DateValue) { this._date = valueOrYear; } else if (valueOrYear instanceof Date) { this._date = new DateValue(valueOrYear); } else if (DateValue.isValid(valueOrYear)) { this._date = new DateValue(valueOrYear); } else { throw new IllegalArgument('Game.date()'); } break; default: if (DateValue.isValid(valueOrYear as number, month, day)) { this._date = new DateValue(valueOrYear as number, month, day); } else { throw new IllegalArgument('Game.date()'); } break; } } /** * Get the date of the game as a standard JavaScript `Date` object. * * If the day of month is undefined for the current game, the returned `Date` object points at the first day of the corresponding month. * If neither the day of month nor the month are undefined for the current game, the returned `Date` object points at the first day of the corresponding year. */ dateAsDate(): Date | undefined { return this._date === undefined ? undefined : this._date.toDate(); } /** * Get the date of the game as a human-readable string (e.g. `'November 1955'`, `'September 4, 2021'`). * * @param locales - Locales to use to generate the result. If undefined, the default locale of the execution environment is used. * See [Intl documentation](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl#locale_identification_and_negotiation) * for more details. */ dateAsString(locales?: string | string[] | undefined): string | undefined { return this._date === undefined ? undefined : this._date.toHumanReadableString(locales); } /** * Get where the game takes place. */ site(): string | undefined; /** * Set where the game takes place. * * @param value - If `undefined`, the existing value (if any) is erased. */ site(value: string | undefined): void; site(value?: string | undefined) { if (arguments.length === 0) { return this._site; } else { this._site = sanitizeStringHeader(value); } } /** * Get the name of the annotator. */ annotator(): string | undefined; /** * Set the name of the annotator. * * @param value - If `undefined`, the existing value (if any) is erased. */ annotator(value: string | undefined): void; annotator(value?: string | undefined) { if (arguments.length === 0) { return this._annotator; } else { this._annotator = sanitizeStringHeader(value); } } /** * Get the [ECO code](https://en.wikipedia.org/wiki/List_of_chess_openings). */ eco(): string | undefined; /** * Set the [ECO code](https://en.wikipedia.org/wiki/List_of_chess_openings). * * @param value - If `undefined`, the existing value (if any) is erased. Must be a valid ECO code (from `'A00'` to `'E99'`). */ eco(value: string | undefined): void; eco(value?: string | undefined) { if (arguments.length === 0) { return this._eco; } else { value = sanitizeStringHeader(value); if (value !== undefined && !isValidECO(value)) { throw new IllegalArgument('Game.eco()'); } this._eco = value; } } /** * Get the name of the opening. */ opening(): string | undefined; /** * Set the name of the opening. * * @param value - If `undefined`, the existing value (if any) is erased. */ opening(value: string | undefined): void; opening(value?: string | undefined) { if (arguments.length === 0) { return this._opening; } else { this._opening = sanitizeStringHeader(value); } } /** * Get the name of the opening variation. */ openingVariation(): string | undefined; /** * Set the name of the opening variation. * * @param value - If `undefined`, the existing value (if any) is erased. */ openingVariation(value: string | undefined): void; openingVariation(value?: string | undefined) { if (arguments.length === 0) { return this._openingVariation; } else { this._openingVariation = sanitizeStringHeader(value); } } /** * Get the name of the opening sub-variation. */ openingSubVariation(): string | undefined; /** * Set the name of the opening sub-variation. * * @param value - If `undefined`, the existing value (if any) is erased. */ openingSubVariation(value: string | undefined): void; openingSubVariation(value?: string | undefined) { if (arguments.length === 0) { return this._openingSubVariation; } else { this._openingSubVariation = sanitizeStringHeader(value); } } /** * Get the reason of the conclusion of the game. Examples of possible values: * * - `'normal'`: game terminated in a normal fashion, * - `'time forfeit'`: loss due to losing player's failure to meet time control requirements, * - `'adjudication'`: result due to third party adjudication process, * - `'death'`: losing player called to greater things, one hopes, * - `'emergency'`: game concluded due to unforeseen circumstances, * - etc... * * This list is not exhaustive and any string is valid value for this field. */ termination(): string | undefined; /** * Set the name of the opening sub-variation. * * @param value - If `undefined`, the existing value (if any) is erased. */ termination(value: string | undefined): void; termination(value?: string | undefined) { if (arguments.length === 0) { return this._termination; } else { this._termination = sanitizeStringHeader(value); } } /** * Get the result of the game. */ result(): GameResult; /** * Set the result of the game. */ result(value: GameResult): void; result(value?: GameResult) { if (arguments.length === 0) { return resultToString(this._result); } else { const resultCode = resultFromString(value); if (resultCode < 0) { throw new IllegalArgument('Game.result()'); } this._result = resultCode; } } /** * Get the chess game variant of the game. */ variant(): GameVariant { return this._moveTreeRoot._position.variant(); } /** * Get the initial position of the game. */ initialPosition(): Position; /** * Set the initial position of the game. * * @param fullMoveNumber - 1 by default */ initialPosition(initialPosition: Position, fullMoveNumber?: number): void; initialPosition(initialPosition?: Position, fullMoveNumber?: number) { if (arguments.length === 0) { return new Position(this._moveTreeRoot._position); } else { if (!(initialPosition instanceof Position)) { throw new IllegalArgument('Game.initialPosition()'); } if (arguments.length >= 2) { if (!Number.isInteger(fullMoveNumber)) { throw new IllegalArgument('Game.initialPosition()'); } this._moveTreeRoot._fullMoveNumber = fullMoveNumber!; } else { this._moveTreeRoot._fullMoveNumber = 1; } this._moveTreeRoot._position = new Position(initialPosition); this._moveTreeRoot.clearTree(); } } /** * [FEN](https://en.wikipedia.org/wiki/Forsyth%E2%80%93Edwards_Notation) representation of the chess position at the beginning of the game. * * The fifty-move clock and full-move number are set according to the move history in the string returned by this method. */ initialFEN(): string { return this._moveTreeRoot._position.fen({ fiftyMoveClock: 0, fullMoveNumber: this._moveTreeRoot._fullMoveNumber, }); } /** * Full-move number at which the game starts. */ initialFullMoveNumber(): number { return this._moveTreeRoot._fullMoveNumber; } /** * Chess position at the end of the game. */ finalPosition(): Position { return this._moveTreeRoot.mainVariation().finalPosition(); } /** * [FEN](https://en.wikipedia.org/wiki/Forsyth%E2%80%93Edwards_Notation) representation of the chess position at the end of the game. * * The fifty-move clock and full-move number are set according to the move history in the string returned by this method. */ finalFEN(): string { return this._moveTreeRoot.mainVariation().finalFEN(); } /** * The main variation of the game. */ mainVariation(): Variation { return this._moveTreeRoot.mainVariation(); } /** * Return the nodes corresponding to the moves of the main variation. * * @param withSubVariations - If `true`, the nodes of the sub-variations are also included in the result. */ nodes(withSubVariations = false): Node[] { if (!withSubVariations) { return this.mainVariation().nodes(); } const result: Node[] = []; function processVariation(variation: Variation) { for (const currentNode of variation.nodes()) { for (const nextVariation of currentNode.variations()) { processVariation(nextVariation); } result.push(currentNode); } } processVariation(this.mainVariation()); return result; } /** * Number of half-moves in the main variation. * * For instance, after `1.e4 e5 2.Nf3`, the number of half-moves if 3 (2 white moves + 1 black move). */ plyCount(): number { return this._moveTreeRoot.mainVariation().plyCount(); } /** * Return the node or variation corresponding to the given ID (see {@link Node.id | Node.id} and {@link Variation.id | Variation.id} * to retrieve the ID of a node or variation). * * For the main variation, IDs are built as follows: * - `'start'` is the ID of the main variation, * - `'3w'` is for instance the ID of the node whose {@link Node.fullMoveNumber} is 3 and {@link Node.moveColor} is white * (i.e. the 3rd white move if the game starts from the usual initial position), * - `'end'` is an alias corresponding to the last node in the main variation (or the main variation itself if it is empty). * * For sub-variations, IDs are built as in the following examples: * - `'2b-v0-start'` is the ID of the sub-variation at index 0 on node `'2b'` (in the main variation), * - `'5w-v3-11b'` is the ID of the node whose {@link Node.fullMoveNumber} is 11 and {@link Node.moveColor} is black within * the sub-variation at index 3 on node `'5w'` (in the main variation), * - `'5w-v3-end'` is an alias corresponding to the last node in this sub-variation (or the sub-variation itself if it is empty). * * @param allowAliases - If `true`, search `id` among both IDs and ID aliases. If `false`, search among IDs only. * @returns `undefined` if the given ID does not correspond to an existing {@link Node} and {@link Variation}. */ findById(id: string, allowAliases = true): Node | Variation | undefined { return this._moveTreeRoot.findById(id, allowAliases); } /** * Return the [POJO](https://en.wikipedia.org/wiki/Plain_old_Java_object) representation of the current {@link Game}. * To be used for JSON serialization, deep cloning, etc... */ pojo(): GamePOJO { const pojo: GamePOJO = {}; function isPlayerPOJOEmpty(game: Game, color: ColorImpl) { return game._playerName[color] === undefined && game._playerElo[color] === undefined && game._playerTitle[color] === undefined; } function getPlayerPOJO(game: Game, color: ColorImpl) { const playerPOJO: PlayerPOJO = {}; if (game._playerName[color] !== undefined) { playerPOJO.name = game._playerName[color]; } if (game._playerElo[color] !== undefined) { playerPOJO.elo = game._playerElo[color]; } if (game._playerTitle[color] !== undefined) { playerPOJO.title = game._playerTitle[color]; } return playerPOJO; } // Headers if (!isPlayerPOJOEmpty(this, ColorImpl.WHITE)) { pojo.white = getPlayerPOJO(this, ColorImpl.WHITE); } if (!isPlayerPOJOEmpty(this, ColorImpl.BLACK)) { pojo.black = getPlayerPOJO(this, ColorImpl.BLACK); } if (this._event !== undefined) { pojo.event = this._event; } if (this._round[RoundPart.ROUND] !== undefined) { pojo.round = this._round[RoundPart.ROUND]; } if (this._round[RoundPart.SUB_ROUND] !== undefined) { pojo.subRound = this._round[RoundPart.SUB_ROUND]; } if (this._round[RoundPart.SUB_SUB_ROUND] !== undefined) { pojo.subSubRound = this._round[RoundPart.SUB_SUB_ROUND]; } if (this._date !== undefined) { pojo.date = this._date.toString(); } if (this._site !== undefined) { pojo.site = this._site; } if (this._annotator !== undefined) { pojo.annotator = this._annotator; } if (this._eco !== undefined) { pojo.eco = this._eco; } if (this._opening !== undefined) { pojo.opening = this._opening; } if (this._openingVariation !== undefined) { pojo.openingVariation = this._openingVariation; } if (this._openingSubVariation !== undefined) { pojo.openingSubVariation = this._openingSubVariation; } if (this._termination !== undefined) { pojo.termination = this._termination; } if (this._result !== GameResultImpl.LINE) { pojo.result = this.result(); } // Moves this._moveTreeRoot.getPojo(pojo); return pojo; } /** * Decode the [POJO](https://en.wikipedia.org/wiki/Plain_old_Java_object) passed in argument, assuming it follows the schema defined by {@link GamePOJO}. * * @throws {@link exception.InvalidPOJO} if the given object cannot be decoded, either because it does not follow the schema defined by {@link GamePOJO}, * or because it would result in an inconsistent game (e.g. if it contains some invalid moves). */ static fromPOJO(pojo: unknown): Game { if (typeof pojo !== 'object' || pojo === null) { throw new InvalidPOJO(pojo, '', i18n.POJO_MUST_BE_AN_OBJECT); } const game = new Game(); const exceptionBuilder = new POJOExceptionBuilder(pojo); function processPlayerPOJO(playerPOJO: Partial<Record<string, unknown>>, color: ColorImpl) { decodeStringField(playerPOJO, 'name', exceptionBuilder, value => { game._playerName[color] = value; }); decodeNumberField(playerPOJO, 'elo', exceptionBuilder, value => { if (!isValidElo(value)) { throw exceptionBuilder.build(i18n.INVALID_ELO_IN_POJO); } game._playerElo[color] = value; }); decodeStringField(playerPOJO, 'title', exceptionBuilder, value => { game._playerTitle[color] = value; }); } function processRoundPart(value: number, roundPart: RoundPart) { if (!isValidRound(value)) { throw exceptionBuilder.build(i18n.INVALID_ROUND_IN_POJO); } game._round[roundPart] = value; } // Headers decodeObjectField(pojo, 'white', exceptionBuilder, value => { processPlayerPOJO(value, ColorImpl.WHITE); }); decodeObjectField(pojo, 'black', exceptionBuilder, value => { processPlayerPOJO(value, ColorImpl.BLACK); }); decodeStringField(pojo, 'event', exceptionBuilder, value => { game._event = value; }); decodeNumberField(pojo, 'round', exceptionBuilder, value => { processRoundPart(value, RoundPart.ROUND); }); decodeNumberField(pojo, 'subRound', exceptionBuilder, value => { processRoundPart(value, RoundPart.SUB_ROUND); }); decodeNumberField(pojo, 'subSubRound', exceptionBuilder, value => { processRoundPart(value, RoundPart.SUB_SUB_ROUND); }); decodeStringField(pojo, 'date', exceptionBuilder, value => { const date = DateValue.fromString(value); if (date === undefined) { throw exceptionBuilder.build(i18n.INVALID_DATE_IN_POJO); } game._date = date; }); decodeStringField(pojo, 'site', exceptionBuilder, value => { game._site = value; }); decodeStringField(pojo, 'annotator', exceptionBuilder, value => { game._annotator = value; }); decodeStringField(pojo, 'eco', exceptionBuilder, value => { if (!isValidECO(value)) { throw exceptionBuilder.build(i18n.INVALID_ECO_CODE_IN_POJO); } game._eco = value; }); decodeStringField(pojo, 'opening', exceptionBuilder, value => { game._opening = value; }); decodeStringField(pojo, 'openingVariation', exceptionBuilder, value => { game._openingVariation = value; }); decodeStringField(pojo, 'openingSubVariation', exceptionBuilder, value => { game._openingSubVariation = value; }); decodeStringField(pojo, 'termination', exceptionBuilder, value => { game._termination = value; }); decodeStringField(pojo, 'result', exceptionBuilder, value => { const resultCode = resultFromString(value); if (resultCode < 0) { throw exceptionBuilder.build(i18n.INVALID_RESULT_IN_POJO); } game._result = resultCode; }); // Moves game._moveTreeRoot.setPojo(pojo, exceptionBuilder); return game; } /** * Return a human-readable string representing the game. This string is multi-line, * and is intended to be displayed in a fixed-width font (similarly to an ASCII-art picture). */ ascii(): string { const lines: string[] = []; function pushIfDefined(header: string | undefined) { if (header !== undefined) { lines.push(header); } } // Headers pushIfDefined(formatEventAndRound(this._event, this._round[RoundPart.ROUND], this._round[RoundPart.SUB_ROUND], this._round[RoundPart.SUB_SUB_ROUND])); pushIfDefined(formatSimpleHeader('Site', this._site)); pushIfDefined(formatSimpleHeader('Date', this.dateAsString('en-us'))); pushIfDefined(formatPlayer('White', this._playerName[ColorImpl.WHITE], this._playerElo[ColorImpl.WHITE], this._playerTitle[ColorImpl.WHITE])); pushIfDefined(formatPlayer('Black', this._playerName[ColorImpl.BLACK], this._playerElo[ColorImpl.BLACK], this._playerTitle[ColorImpl.BLACK])); pushIfDefined(formatSimpleHeader('Annotator', this._annotator)); pushIfDefined(formatSimpleHeader('ECO', this._eco)); pushIfDefined(formatOpening(this._opening, this._openingVariation, this._openingSubVariation)); pushIfDefined(formatSimpleHeader('Termination', this._termination)); // Variant & initial position const variant = this._moveTreeRoot._position.variant(); if (variant !== 'regular') { lines.push('Variant: ' + variant); } if (!variantWithCanonicalStartPosition(variant) || !Position.isEqual(this._moveTreeRoot._position, new Position(variant))) { lines.push(this._moveTreeRoot._position.ascii()); } // Moves & result function isNonEmptyVariation(variation: Variation) { return variation.first() !== undefined || variation.nags().length > 0 || variation.tags().length > 0 || variation.comment() !== undefined; } function dumpNode(node: Node, indent: string, hasSomethingAfter: boolean) { // Describe the move const move = indent + node.fullMoveNumber() + (node.moveColor() === 'w' ? '.' : '...') + node.notation(); const moveAnnotations = formatAnnotations(node); lines.push(moveAnnotations.length === 0 ? move : move + ' ' + moveAnnotations.join(' ')); // Print the sub-variations let atLeastOneNonEmptyVariation = false; for (const variation of node.variations()) { if (isNonEmptyVariation(variation)) { lines.push(indent + ' |'); dumpVariation(variation, indent + (hasSomethingAfter ? ' | ' : ' '), indent + ' +- ', false); atLeastOneNonEmptyVariation = true; } } return atLeastOneNonEmptyVariation; } function dumpVariation(variation: Variation, indent: string, indentFirst: string, hasSomethingAfter: boolean) { // Variation annotations const variationAnnotations = formatAnnotations(variation); if (variationAnnotations.length > 0) { lines.push(indentFirst + variationAnnotations.join(' ')); } // List of moves let node = variation.first(); let atLeastOneVariationInPreviousNode = false; let isFirstNode = true; while (node !== undefined) { if (atLeastOneVariationInPreviousNode) { lines.push(indent + ' |'); } const nextNode = node.next(); atLeastOneVariationInPreviousNode = dumpNode(node, isFirstNode && variationAnnotations.length === 0 ? indentFirst : indent, hasSomethingAfter || nextNode !== undefined); isFirstNode = false; node = nextNode; } } dumpVariation(this._moveTreeRoot.mainVariation(), '', '', false); lines.push(resultToString(this._result)); return lines.join('\n'); } } function sanitizeStringHeader(value: unknown) { return value === undefined || value === null ? undefined : String(value); } function sanitizeNumberHeader(value: unknown) { return value === undefined || value === null ? undefined : Number(value); } function trimCollapseAndMarkEmpty(text: string) { text = trimAndCollapseSpaces(text); return text === '' ? '<empty>' : text; } function formatSimpleHeader(key: string, header: string | undefined) { return header === undefined ? undefined : `${key}: ${trimCollapseAndMarkEmpty(header)}`; } function formatEventAndRound(event: string | undefined, round: number | undefined, subRound: number | undefined, subSubRound: number | undefined) { if (event === undefined && round === undefined && subRound === undefined && subSubRound === undefined) { return undefined; } let result = event === undefined ? 'Event: <undefined>' : `Event: ${trimCollapseAndMarkEmpty(event)}`; const fullRound = formatFullRound(round, subRound, subSubRound, '*'); if (fullRound !== undefined) { result += ` (${fullRound})`; } return result; } function formatFullRound(round: number | undefined, subRound: number | undefined, subSubRound: number | undefined, undefinedToken: string) { if (round === undefined && subRound === undefined && subSubRound === undefined) { return undefined; } let result = round === undefined ? undefinedToken : String(round); if (subRound !== undefined || subSubRound !== undefined) { result += '.' + (subRound ?? undefinedToken); } if (subSubRound !== undefined) { result += '.' + subSubRound; } return result; } function formatPlayer(key: string, playerName: string | undefined, playerElo: number | undefined, playerTitle: string | undefined) { if (playerName === undefined && playerElo === undefined && playerTitle === undefined) { return undefined; } let result = playerName === undefined ? `${key}: <undefined>` : `${key}: ${trimCollapseAndMarkEmpty(playerName)}`; if (playerElo !== undefined && playerTitle !== undefined) { result += ` (${trimCollapseAndMarkEmpty(playerTitle)} ${playerElo})`; } else if (playerElo !== undefined) { result += ` (${playerElo})`; } else if (playerTitle !== undefined) { result += ` (${trimCollapseAndMarkEmpty(playerTitle)})`; } return result; } function formatOpening(opening: string | undefined, openingVariation: string | undefined, openingSubVariation: string | undefined) { if (opening === undefined && openingVariation === undefined && openingSubVariation === undefined) { return undefined; } let result = opening === undefined ? 'Opening: <undefined>' : `Opening: ${trimCollapseAndMarkEmpty(opening)}`; if (openingSubVariation !== undefined) { result += ` (${openingVariation === undefined ? '<undefined>' : trimCollapseAndMarkEmpty(openingVariation)}, ${trimCollapseAndMarkEmpty(openingSubVariation)})`; } else if (openingVariation !== undefined) { result += ` (${trimCollapseAndMarkEmpty(openingVariation)})`; } return result; } function formatAnnotations(node: AbstractNode) { const result: string[] = []; // NAGs for (const nag of node.nags()) { result.push(nagSymbol(nag)); } // Tags for (const tagKey of node.tags()) { result.push(`${tagKey}={${trimAndCollapseSpaces(node.tag(tagKey)!)}}`); } // Comment const comment = node.comment(); if (comment !== undefined) { result.push(trimCollapseAndMarkEmpty(comment)); } return result; }