diff --git a/docs/examples/preset-moves/index.html b/docs/examples/preset-moves/index.html
index 0c524a1..22ede5f 100644
--- a/docs/examples/preset-moves/index.html
+++ b/docs/examples/preset-moves/index.html
@@ -38,18 +38,10 @@
@@ -96,6 +96,7 @@
Example: Preset Moves
En passant captures are notated with an $ at the end. This is done to simplify the format of notations into a standard of a square, followed by x or -, followed by a square, and then either $ or =.
Since hexagonal chess has no castling, we do not need to deal with that notation.
Lastly, promotion is handled with the = sign. So a white pawn that moved from A5 to A6 and became a Rook would be written as A5-A6=R.
+
If a player resigns instead of being checkmated, append ,R to the end of the moves list. The alternating turn order already encodes which player resigned, so the marker only needs to appear once at the end.
<div style="width: 575px; height: 500px">
<hexchess-board
id="hexchess-board"
board="start"
orientation="white"
moves="B1-B3,B7-B5,C2-C4,D7-D5,C4xD5p,F11xB3P,C1xC7p"
></hexchess-board>
</div>
{
promotion: 'N',
},
];
- expect(movesToString(moves)).toBe('A1-A2,B2-B1=q,C2-C4,D4xC3P$,B6xA6r=N');
+ const encoded = 'A1-A2,B2-B1=q,C2-C4,D4xC3P$,B6xA6r=N';
+ expect(movesToString(moves)).toBe(encoded);
+ expect(movesToString(moves, true)).toBe(`${encoded},R`);
+ });
+
+ test('Move strings round-trip without losing information', () => {
+ const moves: Move[] = [
+ { from: 'B1', to: 'B3', enPassant: false, promotion: null },
+ { from: 'C7', to: 'C5', enPassant: false, promotion: null },
+ { from: 'B3', to: 'B4', enPassant: false, promotion: null },
+ {
+ from: 'C5',
+ to: 'B4',
+ capturedPiece: 'P',
+ enPassant: false,
+ promotion: null,
+ },
+ { from: 'D3', to: 'D5', enPassant: false, promotion: null },
+ { from: 'E7', to: 'E5', enPassant: false, promotion: null },
+ {
+ from: 'D5',
+ to: 'E6',
+ capturedPiece: 'p',
+ enPassant: true,
+ promotion: null,
+ },
+ { from: 'B4', to: 'B3', enPassant: false, promotion: null },
+ {
+ from: 'E6',
+ to: 'F7',
+ capturedPiece: 'p',
+ enPassant: false,
+ promotion: null,
+ },
+ { from: 'B3', to: 'B2', enPassant: false, promotion: null },
+ {
+ from: 'F7',
+ to: 'G7',
+ capturedPiece: 'p',
+ enPassant: false,
+ promotion: null,
+ },
+ { from: 'B2', to: 'B1', enPassant: false, promotion: 'q' },
+ ];
+
+ const encoded = movesToString(moves);
+ expect(stringToMoves(encoded)).toEqual(moves);
+ });
+
+ test('Move strings round-trip when resignation is included', () => {
+ const moves: Move[] = [
+ { from: 'A1', to: 'A2', enPassant: false, promotion: null },
+ {
+ from: 'B2',
+ to: 'A3',
+ capturedPiece: 'n',
+ enPassant: false,
+ promotion: null,
+ },
+ ];
+
+ const encoded = movesToString(moves, true);
+ expect(encoded.endsWith(',R')).toBe(true);
+ expect(stringToMoves(encoded)).toEqual(moves);
});
test('Converts a CSV string to moves properly', () => {
@@ -79,6 +142,55 @@ describe('Utils', () => {
expect(stringToMoves(moveStr)).toEqual(moves);
});
+ test('stringToMoves handles a resignation marker', () => {
+ const base =
+ 'B1-B3,C7-C5,B3-B4,C5xB4P,D3-D5,E7-E5,D5xE6p$,B4-B3,E6xF7p,B3-B2,F7xG7p,B2-B1=q';
+ const expected: Move[] = [
+ { from: 'B1', to: 'B3', enPassant: false, promotion: null },
+ { from: 'C7', to: 'C5', enPassant: false, promotion: null },
+ { from: 'B3', to: 'B4', enPassant: false, promotion: null },
+ {
+ from: 'C5',
+ to: 'B4',
+ capturedPiece: 'P',
+ enPassant: false,
+ promotion: null,
+ },
+ { from: 'D3', to: 'D5', enPassant: false, promotion: null },
+ { from: 'E7', to: 'E5', enPassant: false, promotion: null },
+ {
+ from: 'D5',
+ to: 'E6',
+ capturedPiece: 'p',
+ enPassant: true,
+ promotion: null,
+ },
+ { from: 'B4', to: 'B3', enPassant: false, promotion: null },
+ {
+ from: 'E6',
+ to: 'F7',
+ capturedPiece: 'p',
+ enPassant: false,
+ promotion: null,
+ },
+ { from: 'B3', to: 'B2', enPassant: false, promotion: null },
+ {
+ from: 'F7',
+ to: 'G7',
+ capturedPiece: 'p',
+ enPassant: false,
+ promotion: null,
+ },
+ { from: 'B2', to: 'B1', enPassant: false, promotion: 'q' },
+ ];
+
+ expect(stringToMoves(`${base},R`)).toEqual(expected);
+ expect(stringToMoves('R')).toEqual([]);
+ expect(() => stringToMoves('R,B1-B3')).toThrow(
+ 'Cannot process resignation when subsequent moves still exist.',
+ );
+ });
+
test('fenToBoard falls back to an empty board for invalid input', () => {
expect(fenToBoard('invalid').numPieces()).toBe(0);
expect(fenToBoard(null).numPieces()).toBe(0);
diff --git a/src/utils.ts b/src/utils.ts
index 4aa776b..aec6c5a 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -1,6 +1,8 @@
import { Board } from './board';
import type { Color, Move, Piece } from './types';
+export const RESIGNATION_MARKER = 'R';
+
export const COLUMN_ARRAY = [
'A',
'B',
@@ -188,7 +190,7 @@ export const fenToBoard = (position: string | null): Board => {
return converted;
};
-export const movesToString = (moves: Move[]): string => {
+export const movesToString = (moves: Move[], resigned = false): string => {
const result: string[] = [];
for (const move of moves) {
let newString = move.capturedPiece
@@ -202,6 +204,9 @@ export const movesToString = (moves: Move[]): string => {
}
result.push(newString);
}
+ if (resigned) {
+ result.push(RESIGNATION_MARKER);
+ }
return result.join(',');
};
@@ -214,7 +219,16 @@ export const stringToMoves = (movesStr: string): Move[] => {
const moves = movesStr.split(',');
const result: Move[] = [];
- for (const move of moves) {
+ for (let i = 0; i < moves.length; i++) {
+ const move = moves[i];
+ if (move === RESIGNATION_MARKER) {
+ if (i !== moves.length - 1) {
+ throw new Error(
+ 'Cannot process resignation when subsequent moves still exist.',
+ );
+ }
+ break;
+ }
if (!moveRegex.test(move)) {
throw new Error(`Invalid move: ${move}`);
}