diff --git a/.eslintrc.cjs b/.eslintrc.cjs index c5db25fc..370e15e8 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -27,6 +27,7 @@ module.exports = { format: ['PascalCase'], }, ], - 'multiline-comment-style': ['error', 'starred-block'] + 'multiline-comment-style': ['error', 'starred-block'], + "@typescript-eslint/no-non-null-assertion": 'allow' }, } diff --git a/README.md b/README.md index 07c61f31..f06152df 100644 --- a/README.md +++ b/README.md @@ -424,10 +424,16 @@ chess.isCheckmate() // -> true ``` -### .isDraw() +### .canClaimDraw() -Returns true or false if the game is drawn (50-move rule or insufficient -material). +Returns true or false if a draw can be claimed in the current position as per Article 9 of the [FIDE Laws of Chess](https://handbook.fide.com/chapter/E012023), ie. if `.isThreefoldRepetition()` or `.isFiftyMoveRule()`. + + +### .isDraw({ strict = false }: { strict?: boolean } = {}) + +Returns true or false if the game is drawn (by stalemate, insufficient material, threefold repetition or 50-move rule). + +If optional `{ strict: true }` argument is given, returns true or false if the game is strictly drawn as per Article 9 of the [FIDE Laws of Chess](https://handbook.fide.com/chapter/E012023) (by stalemate, insufficient material, fivefold repetition or 75-move rule). ```ts const chess = new Chess('4k3/4P3/4K3/8/8/8/8/8 b - - 0 78') @@ -435,6 +441,19 @@ chess.isDraw() // -> true ``` +```ts +const chess = new Chess('8/8/8/2R5/8/7K/8/k2r4 w - - 149 129') +chess.isDraw() +// -> true + +chess.isDraw({ strict: true }) +// -> false + +chess.move('Rd5') +chess.isDraw({ strict: true }) +// -> true +``` + ### .isInsufficientMaterial() Returns true if the game is drawn due to insufficient material (K vs. K, K vs. @@ -446,10 +465,11 @@ chess.isInsufficientMaterial() // -> true ``` -### .isGameOver() +### .isGameOver({ strict = false }: { strict?: boolean } = {}) + +Returns true if the game has ended via checkmate or draw (by stalemate, insufficient material, threefold repetition or 50-move rule). Otherwise, returns false. -Returns true if the game has ended via checkmate, stalemate, draw, threefold -repetition, or insufficient material. Otherwise, returns false. +If optional `{ strict: true }` argument is given, it determines whether the game is strictly drawn as per Article 9 of the [FIDE Laws of Chess](https://handbook.fide.com/chapter/E012023) (by stalemate, insufficient material, fivefold repetition or 75-move rule). ```ts const chess = new Chess() @@ -499,6 +519,73 @@ chess.move('Nf3') chess.move('Nf6') chess.move('Ng1') chess.move('Ng8') chess.isThreefoldRepetition() // -> true ``` +### .isFivefoldRepetition() + +Returns true or false if the current board position has occurred five or more +times. + +```ts +const chess = new Chess('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1') +// -> true +// rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq occurs 1st time +chess.isFivefoldRepetition() +// -> false + +chess.move('Nf3') chess.move('Nf6') chess.move('Ng1') chess.move('Ng8') +// rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq occurs 2nd time +chess.isFivefoldRepetition() +// -> false + +chess.move('Nf3') chess.move('Nf6') chess.move('Ng1') chess.move('Ng8') +chess.move('Nf3') chess.move('Nf6') chess.move('Ng1') chess.move('Ng8') +chess.move('Nf3') chess.move('Nf6') chess.move('Ng1') chess.move('Ng8') +// rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq occurs 5th time +chess.isFivefoldRepetition() +// -> true +``` + +### .isFiftyMoveRule() + +Returns true or false if the [fifty-move rule](https://en.wikipedia.org/wiki/Fifty-move_rule) is in effect. + +```ts +const chess = new Chess('8/2R5/5K2/1k6/8/8/8/4r3 w - - 99 104') + +chess.isFiftyMoveRule() +// -> false + +chess.move('Kf5') +chess.isFiftyMoveRule() +// -> true + +chess.move('Re5') +chess.isFiftyMoveRule() +// -> true + +chess.move('Kxe5') +chess.isFiftyMoveRule() +// -> false +``` + +### .isSeventyFiveMoveRule() + +Returns true or false if the [seventy-five-move rule](https://en.wikipedia.org/wiki/Fifty-move_rule#Seventy-five-move_rule) is in effect. + + +```ts +const chess = new Chess('8/8/8/2R5/8/7K/8/k2r4 w - - 149 129') + +chess.isSeventyFiveMoveRule() +// -> false + +chess.move('Rd5') +chess.isSeventyFiveMoveRule() +// -> true + +chess.move('Rxd5') +chess.isSeventyFiveMoveRule() +// -> false +``` ### .load(fen) diff --git a/__tests__/comments.test.ts b/__tests__/comments.test.ts index 066af598..191ef4ae 100644 --- a/__tests__/comments.test.ts +++ b/__tests__/comments.test.ts @@ -244,6 +244,21 @@ describe('Load Comments', () => { {test comment} 16. ... Rfb8`, }, + { + name: 'bracket comments without spaces', + input: '1. e4{e4 is good}e5', + output: '1. e4 {e4 is good} e5', + }, + { + name: 'semicolon comments without spaces', + input: '1. e4;e4 is good\ne5', + output: '1. e4 {e4 is good} e5', + }, + { + name: 'multiple comments for the same move', + input: '1. e4 {e4 is good} {it is well studied} e5', + output: '1. e4 {e4 is good it is well studied} e5', + }, ] tests.forEach((test) => { @@ -254,3 +269,54 @@ describe('Load Comments', () => { }) }) }) + +describe('Put/remove comments', () => { + it('adds puts as comments', () => { + const chess = new Chess() + chess.move('e4') + chess.put({type: 'p', color: 'w'}, 'e2') + expect(chess.pgn()).toBe('1. e4 {Chess.js: w p put on e2}') + }) + it('adds removes as comments', () => { + const chess = new Chess() + chess.move('e4') + chess.remove('d2') + expect(chess.pgn()).toBe('1. e4 {Chess.js: w p removed from d2}') + }) + it('adds multiple puts/removes in the same turn', () => { + const chess = new Chess() + chess.move('e4') + chess.put({type: 'p', color: 'w'}, 'e2') + chess.remove('d2') + expect(chess.pgn()).toBe('1. e4 {Chess.js: w p put on e2} {Chess.js: w p removed from d2}') + }) + it('loads puts from comments', () => { + const chess = new Chess() + chess.loadPgn('1. e4 {Chess.js: w p put on e2}') + expect(chess.fen()).toBe('rnbqkbnr/pppppppp/8/8/4P3/8/PPPPPPPP/RNBQKBNR b KQkq - 0 1') + }) + it('loads removes from comments', () => { + const chess = new Chess() + chess.loadPgn('1. e4 {Chess.js: w p removed from d2}') + expect(chess.fen()).toBe('rnbqkbnr/pppppppp/8/8/4P3/8/PPP2PPP/RNBQKBNR b KQkq - 0 1') + }) + it('loads multiple puts/removes in the same turn', () => { + const chess = new Chess() + chess.loadPgn('1. e4 {Chess.js: w p put on e2} {Chess.js: w p removed from d2}') + expect(chess.fen()).toBe('rnbqkbnr/pppppppp/8/8/4P3/8/PPP1PPPP/RNBQKBNR b KQkq - 0 1') + }) + it('can .loadPgn with puts generated by .pgn', () => { + const chess = new Chess() + chess.loadPgn('1. e4 f6 2. Qh5+') + chess.put({type: 'p', color:'b'}, 'f7') + chess.move('Nc6') // illegal move without the put + expect(() => chess.loadPgn(chess.pgn())).not.toThrow() + }) + it('can .loadPgn with removes generated by .pgn', () => { + const chess = new Chess() + chess.loadPgn('1. g3 e6 2. Bg2 Ne7 3. Bc6') + chess.remove('b8') + chess.move('Nxc6') // ambiguous SAN without the remove + expect(() => chess.loadPgn(chess.pgn())).not.toThrow() + }) +}) \ No newline at end of file diff --git a/__tests__/is-draw.test.ts b/__tests__/is-draw.test.ts new file mode 100644 index 00000000..f0b3ced1 --- /dev/null +++ b/__tests__/is-draw.test.ts @@ -0,0 +1,58 @@ +import { Chess } from '../src/chess' + +test('isDraw - strict mode, fivefold repetition', () => { + const moves = 'Nf3 Nf6 Ng1 Ng8 Nf3 Nf6 Ng1 Ng8 Nf3 Nf6 Ng1 Ng8'.split(/\s+/) + const chess = new Chess() + + moves.forEach((move) => { + expect(chess.isDraw({strict: true})).toBe(false) + chess.move(move) + }) + + expect(chess.isDraw()).toBe(true) + expect(chess.isDraw({strict: true})).toBe(false) + chess.move('Nf3') + chess.move('Nf6') + chess.move('Ng1') + chess.move('Ng8') + expect(chess.isDraw({strict: true})).toBe(true) + +}) + +test('isDraw - strict mode, seventy-five-move rule', () => { + const pgn = `1.e4 e5 2.Nf3 Nc6 3.Bb5 a6 4.Ba4 Nf6 5.O-O Nxe4 6.d4 b5 7.Bb3 d5 8.dxe5 Be6 + 9.c3 Be7 10.Re1 O-O 11.Nbd2 Nc5 12.Bc2 d4 13.Nf1 d3 14.Bb3 Nxb3 15.axb3 Qd7 16.Ng5 Bf5 + 17.Ng3 Bg6 18.h4 Rfd8 19.Bd2 h6 20.h5 Bf5 21.Nxf5 Qxf5 22.Qf3 Qxf3 23.Nxf3 a5 24.Re4 Rd5 + 25.Rae1 Rad8 26.g4 Ra8 27.Kg2 Kf8 28.Nd4 Nxd4 29.Rxd4 Rxd4 30.cxd4 Rd8 31.Be3 Rd5 32.Rd1 + c5 33.dxc5 Bxc5 34.Bxc5+ Rxc5 35.Rxd3 Rxe5 36.Rd8+ Ke7 37.Ra8 b4 38.Kf3 Rc5 39.Ra7+ Ke6 + 40.Ke4 Rc2 41.Ra6+ Ke7 42.Rxa5 Rxb2 43.f4 Rxb3 44.Rb5 Rb1 45.g5 b3 46.gxh6 gxh6 47.Kf5 b2 + 48.Rb7+ Ke8 49.Kf6 Rh1 50.Rxb2 Rxh5 51.Rb8+ Kd7 52.Kxf7 Rf5+ 53.Kg6 Rxf4 54.Kxh6 Ke6 + 55.Rb6+ Kf7 56.Rb7+ Kf6 57.Rb6+ Kf5 58.Rb5+ Kg4 59.Rg5+ Kf3 60.Rb5 Ra4 61.Kg5 Ra1 62.Rb3+ + Ke4 63.Rb4+ Kd5 64.Rb5+ Kc6 65.Rb2 Kc5 66.Kf4 Kd4 67.Rd2+ Kc3 68.Rd7 Kc2 69.Ke3 Ra3+ + 70.Ke4 Ra4+ 71.Ke5 Ra8 72.Ke4 Kc3 73.Kf5 Ra5+ 74.Kg4 Kc4 75.Kh4 Ra4 76.Kh3 Kc5 77.Kh2 + Kc6 78.Rd2 Kc5 79.Kh3 Ra3+ 80.Kh4 Kc6 81.Kh5 Kc7 82.Kh6 Kc8 83.Kh7 Rh3+ 84.Kg6 Kc7 85.Kf7 + Kc6 86.Ke6 Kc5 87.Kf5 Kc4 88.Kg4 Rh8 89.Rd7 Rg8+ 90.Kf3 Kc5 91.Kf4 Rf8+ 92.Ke5 Kc6 93.Rd2 + Re8+ 94.Kf4 Rf8+ 95.Ke5 Kc5 96.Ke4 Kc4 97.Rc2+ Kb3 98.Rc7 Re8+ 99.Kd5 Rd8+ 100.Ke6 Kb4 + 101.Kf6 Rd1 102.Ke6 Kb5 103.Kf6 Re1 104.Kf5` + const moves = pgn.split(/\s*\d+\.\s*|\s+/).slice(1) + const strictPgn = `104.Rd1 105.Rc8 Ka5 106.Kf4 Re1 107.Kg4 Ka4 108.Rb8 + Rf1 109.Kg5 Ka3 110.Rc8 Ka2 111.Kh5 Re1 112.Kg5 Ka1 113.Rc7 Rf1 114.Rd7 Kb2 115.Kg6 Re1 116. + Rc7 Kb1 117.Kg5 Re2 118.Rc4 Re6 119.Kg4 Kb2 120.Kg3 Ka3 121.Rc2 Re8 122.Kg2 Ka4 123.Rc4+ Ka3 + 124.Kg1 Re2 125.Rc5 Ka2 126.Kh1 Re1+ 127.Kh2 Ka1 128.Kh3 Rd1 129.Rd5` + const strictMoves = strictPgn.split(/\s*\d+\.\s*|\s+/).slice(1) + + const chess = new Chess() + moves.forEach((move) => { + expect(chess.isDraw()).toBe(false) + expect(chess.isDraw({strict: true})).toBe(false) + chess.move(move) + }) + + strictMoves.forEach((move) => { + expect(chess.isDraw()).toBe(true) + expect(chess.isDraw({strict: true})).toBe(false) + chess.move(move) + }) + expect(chess.isDraw()).toBe(true) + expect(chess.isDraw({strict: true})).toBe(true) +}) diff --git a/__tests__/is-fifty-move-rule.test.ts b/__tests__/is-fifty-move-rule.test.ts new file mode 100644 index 00000000..d2e3c026 --- /dev/null +++ b/__tests__/is-fifty-move-rule.test.ts @@ -0,0 +1,33 @@ +import { Chess } from '../src/chess' + +test('isFiftyMoveRule', () => { + const pgn = `1. e4 e5 2. Nf3 Nc6 3. Bb5 a6 4. Ba4 Nf6 5. O-O Nxe4 6. d4 b5 7. Bb3 d5 8. dxe5 + Be6 9. c3 Be7 10. Re1 O-O 11. Nbd2 Nc5 12. Bc2 d4 13. Nf1 d3 14. Bb3 Nxb3 15. + axb3 Qd7 16. Ng5 Bf5 17. Ng3 Bg6 18. h4 Rfd8 19. Bd2 h6 20. h5 Bf5 21. Nxf5 Qxf5 + 22. Qf3 Qxf3 23. Nxf3 a5 24. Re4 Rd5 25. Rae1 Rad8 26. g4 Ra8 27. Kg2 Kf8 28. + Nd4 Nxd4 29. Rxd4 Rxd4 30. cxd4 Rd8 31. Be3 Rd5 32. Rd1 c5 33. dxc5 Bxc5 34. + Bxc5+ Rxc5 35. Rxd3 Rxe5 36. Rd8+ Ke7 37. Ra8 b4 38. Kf3 Rc5 39. Ra7+ Ke6 40. + Ke4 Rc2 41. Ra6+ Ke7 42. Rxa5 Rxb2 43. f4 Rxb3 44. Rb5 Rb1 45. g5 b3 46. gxh6 + gxh6 47. Kf5 b2 48. Rb7+ Ke8 49. Kf6 Rh1 50. Rxb2 Rxh5 51. Rb8+ Kd7 52. Kxf7 + Rf5+ 53. Kg6 Rxf4 54. Kxh6 Ke6 55. Rb6+ Kf7 56. Rb7+ Kf6 57. Rb6+ Kf5 58. Rb5+ + Kg4 59. Rg5+ Kf3 60. Rb5 Ra4 61. Kg5 Ra1 62. Rb3+ Ke4 63. Rb4+ Kd5 64. Rb5+ Kc6 + 65. Rb2 Kc5 66. Kf4 Kd4 67. Rd2+ Kc3 68. Rd7 Kc2 69. Ke3 Ra3+ 70. Ke4 Ra4+ 71. + Ke5 Ra8 72. Ke4 Kc3 73. Kf5 Ra5+ 74. Kg4 Kc4 75. Kh4 Ra4 76. Kh3 Kc5 77. Kh2 Kc6 + 78. Rd2 Kc5 79. Kh3 Ra3+ 80. Kh4 Kc6 81. Kh5 Kc7 82. Kh6 Kc8 83. Kh7 Rh3+ 84. + Kg6 Kc7 85. Kf7 Kc6 86. Ke6 Kc5 87. Kf5 Kc4 88. Kg4 Rh8 89. Rd7 Rg8+ 90. Kf3 Kc5 + 91. Kf4 Rf8+ 92. Ke5 Kc6 93. Rd2 Re8+ 94. Kf4 Rf8+ 95. Ke5 Kc5 96. Ke4 Kc4 97. + Rc2+ Kb3 98. Rc7 Re8+ 99. Kd5 Rd8+ 100. Ke6 Kb4 101. Kf6 Rd1 102. Ke6 Kb5 103. + Kf6 Re1 104. Kf5` + const moves = pgn.split(/\s*\d+\.\s*|\s+/).slice(1) + + const chess = new Chess() + moves.forEach((move) => { + expect(chess.isFiftyMoveRule()).toBe(false) + chess.move(move) + }) + expect(chess.isFiftyMoveRule()).toBe(true) + chess.move('Re5') + expect(chess.isFiftyMoveRule()).toBe(true) + chess.move('Kxe5') + expect(chess.isFiftyMoveRule()).toBe(false) +}) diff --git a/__tests__/is-fivefold-repetition.test.ts b/__tests__/is-fivefold-repetition.test.ts new file mode 100644 index 00000000..a90cdf8f --- /dev/null +++ b/__tests__/is-fivefold-repetition.test.ts @@ -0,0 +1,13 @@ +import { Chess } from '../src/chess' + +test('isFivefoldRepetition', () => { + const moves = 'Nf3 Nf6 Ng1 Ng8 Nf3 Nf6 Ng1 Ng8 Nf3 Nf6 Ng1 Ng8 Nf3 Nf6 Ng1 Ng8'.split(/\s+/) + const chess = new Chess() + moves.forEach((move) => { + expect(chess.isFivefoldRepetition()).toBe(false) + chess.move(move) + }) + expect(chess.isFivefoldRepetition()).toBe(true) + chess.move('e4') + expect(chess.isFivefoldRepetition()).toBe(false) +}) diff --git a/__tests__/is-seventy-five-move-rule.test.ts b/__tests__/is-seventy-five-move-rule.test.ts new file mode 100644 index 00000000..75e48813 --- /dev/null +++ b/__tests__/is-seventy-five-move-rule.test.ts @@ -0,0 +1,31 @@ +import { Chess } from '../src/chess' + +test('isSeventyFiveMoveRule', () => { + const pgn = `1.e4 e5 2.Nf3 Nc6 3.Bb5 a6 4.Ba4 Nf6 5.O-O Nxe4 6.d4 b5 7.Bb3 d5 8.dxe5 Be6 + 9.c3 Be7 10.Re1 O-O 11.Nbd2 Nc5 12.Bc2 d4 13.Nf1 d3 14.Bb3 Nxb3 15.axb3 Qd7 16.Ng5 Bf5 + 17.Ng3 Bg6 18.h4 Rfd8 19.Bd2 h6 20.h5 Bf5 21.Nxf5 Qxf5 22.Qf3 Qxf3 23.Nxf3 a5 24.Re4 Rd5 + 25.Rae1 Rad8 26.g4 Ra8 27.Kg2 Kf8 28.Nd4 Nxd4 29.Rxd4 Rxd4 30.cxd4 Rd8 31.Be3 Rd5 32.Rd1 + c5 33.dxc5 Bxc5 34.Bxc5+ Rxc5 35.Rxd3 Rxe5 36.Rd8+ Ke7 37.Ra8 b4 38.Kf3 Rc5 39.Ra7+ Ke6 + 40.Ke4 Rc2 41.Ra6+ Ke7 42.Rxa5 Rxb2 43.f4 Rxb3 44.Rb5 Rb1 45.g5 b3 46.gxh6 gxh6 47.Kf5 b2 + 48.Rb7+ Ke8 49.Kf6 Rh1 50.Rxb2 Rxh5 51.Rb8+ Kd7 52.Kxf7 Rf5+ 53.Kg6 Rxf4 54.Kxh6 Ke6 + 55.Rb6+ Kf7 56.Rb7+ Kf6 57.Rb6+ Kf5 58.Rb5+ Kg4 59.Rg5+ Kf3 60.Rb5 Ra4 61.Kg5 Ra1 62.Rb3+ + Ke4 63.Rb4+ Kd5 64.Rb5+ Kc6 65.Rb2 Kc5 66.Kf4 Kd4 67.Rd2+ Kc3 68.Rd7 Kc2 69.Ke3 Ra3+ + 70.Ke4 Ra4+ 71.Ke5 Ra8 72.Ke4 Kc3 73.Kf5 Ra5+ 74.Kg4 Kc4 75.Kh4 Ra4 76.Kh3 Kc5 77.Kh2 + Kc6 78.Rd2 Kc5 79.Kh3 Ra3+ 80.Kh4 Kc6 81.Kh5 Kc7 82.Kh6 Kc8 83.Kh7 Rh3+ 84.Kg6 Kc7 85.Kf7 + Kc6 86.Ke6 Kc5 87.Kf5 Kc4 88.Kg4 Rh8 89.Rd7 Rg8+ 90.Kf3 Kc5 91.Kf4 Rf8+ 92.Ke5 Kc6 93.Rd2 + Re8+ 94.Kf4 Rf8+ 95.Ke5 Kc5 96.Ke4 Kc4 97.Rc2+ Kb3 98.Rc7 Re8+ 99.Kd5 Rd8+ 100.Ke6 Kb4 + 101.Kf6 Rd1 102.Ke6 Kb5 103.Kf6 Re1 104.Kf5 Rd1 105.Rc8 Ka5 106.Kf4 Re1 107.Kg4 Ka4 108.Rb8 + Rf1 109.Kg5 Ka3 110.Rc8 Ka2 111.Kh5 Re1 112.Kg5 Ka1 113.Rc7 Rf1 114.Rd7 Kb2 115.Kg6 Re1 116. + Rc7 Kb1 117.Kg5 Re2 118.Rc4 Re6 119.Kg4 Kb2 120.Kg3 Ka3 121.Rc2 Re8 122.Kg2 Ka4 123.Rc4+ Ka3 + 124.Kg1 Re2 125.Rc5 Ka2 126.Kh1 Re1+ 127.Kh2 Ka1 128.Kh3 Rd1 129.Rd5` + const moves = pgn.split(/\s*\d+\.\s*|\s+/).slice(1) + + const chess = new Chess() + moves.forEach((move) => { + expect(chess.isSeventyFiveMoveRule()).toBe(false) + chess.move(move) + }) + expect(chess.isSeventyFiveMoveRule()).toBe(true) + chess.move('Rxd5') + expect(chess.isSeventyFiveMoveRule()).toBe(false) +}) diff --git a/__tests__/is-threefold-repetition.test.ts b/__tests__/is-threefold-repetition.test.ts index 908a79a1..c8ab41db 100644 --- a/__tests__/is-threefold-repetition.test.ts +++ b/__tests__/is-threefold-repetition.test.ts @@ -12,6 +12,8 @@ test('isThreefoldRepetition', () => { chess.move(move) }) expect(chess.isThreefoldRepetition()).toBe(true) + chess.move('a6') + expect(chess.isThreefoldRepetition()).toBe(false) }) test('isThreefoldRepetition - 2', () => { @@ -22,4 +24,6 @@ test('isThreefoldRepetition - 2', () => { chess.move(move) }) expect(chess.isThreefoldRepetition()).toBe(true) + chess.move('e4') + expect(chess.isThreefoldRepetition()).toBe(false) }) diff --git a/__tests__/position-counts.test.ts b/__tests__/position-counts.test.ts new file mode 100644 index 00000000..c0fbac7f --- /dev/null +++ b/__tests__/position-counts.test.ts @@ -0,0 +1,117 @@ +import { Chess as ChessClass, DEFAULT_POSITION } from '../src/chess' + +// We need to use `Chess as any` to access private property +// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/naming-convention +const Chess = ChessClass as any +const e4Fen = 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1' + +test('_positionCounts - counts repeated positions', () => { + const chess = new Chess() + expect(chess._positionCounts[DEFAULT_POSITION]).toBe(1) + + const fens: string[] = [DEFAULT_POSITION] + const moves: string[] = ['Nf3', 'Nf6', 'Ng1', 'Ng8'] + for (const move of moves) { + for (const fen of fens) { + expect(chess._positionCounts[fen]).toBe(1) + } + chess.move(move) + fens.push(chess.fen()) + } + expect(chess._positionCounts[DEFAULT_POSITION]).toBe(2) + expect(chess._positionCounts.length).toBe(4) +}) + +test('_positionCounts - removes when undo', () => { + const chess = new Chess() + expect(chess._positionCounts[DEFAULT_POSITION]).toBe(1) + expect(chess._positionCounts[e4Fen]).toBe(0) + chess.move('e4') + expect(chess._positionCounts[DEFAULT_POSITION]).toBe(1) + expect(chess.fen()).toBe(e4Fen) + expect(chess._positionCounts[e4Fen]).toBe(1) + + chess.undo() + expect(chess._positionCounts[DEFAULT_POSITION]).toBe(1) + expect(chess._positionCounts[e4Fen]).toBe(0) + expect(chess._positionCounts.length).toBe(1) +}) + +test('_positionCounts - resets when cleared', () => { + const chess = new Chess() + + chess.move('e4') + chess.clear() + expect(chess._positionCounts[DEFAULT_POSITION]).toBe(0) + expect(chess._positionCounts.length).toBe(0) +}) + +test('_positionCounts - resets when loading FEN', () => { + const chess = new Chess() + expect(chess._positionCounts[DEFAULT_POSITION]).toBe(1) + chess.move('e4') + expect(chess._positionCounts[DEFAULT_POSITION]).toBe(1) + expect(chess._positionCounts[e4Fen]).toBe(1) + + const newFen = 'rnbqkbnr/pp1ppppp/8/2p5/4P3/5N2/PPPP1PPP/RNBQKB1R b KQkq - 1 2' + chess.load(newFen) + expect(chess._positionCounts[DEFAULT_POSITION]).toBe(0) + expect(chess._positionCounts[e4Fen]).toBe(0) + expect(chess._positionCounts[newFen]).toBe(1) + expect(chess._positionCounts.length).toBe(1) +}) + +test('_positionCounts - resets when loading PGN', () => { + const chess = new Chess() + chess.move('e4') + + chess.loadPgn('1. d4 d5') + expect(chess._positionCounts[DEFAULT_POSITION]).toBe(1) + expect(chess._positionCounts[e4Fen]).toBe(0) + expect(chess._positionCounts['rnbqkbnr/pppppppp/8/8/3P4/8/PPP1PPPP/RNBQKBNR b KQkq - 0 1']).toBe(1) + expect(chess._positionCounts['rnbqkbnr/ppp1pppp/8/3p4/3P4/8/PPP1PPPP/RNBQKBNR w KQkq - 0 2']).toBe(1) + expect(chess._positionCounts.length).toBe(3) +}) + +test('_positionCounts - properly updates if put is called', () => { + const chess = new Chess() + expect(chess._positionCounts[DEFAULT_POSITION]).toBe(1) + chess.move('e4') + expect(chess._positionCounts[e4Fen]).toBe(1) + + chess.put({type: 'p', color: 'w'}, 'e2') + chess.move('e5') + expect(chess._positionCounts['rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPPPPPP/RNBQKBNR w KQkq - 0 3']).toBe(1) +}) + +test('_positionCounts - properly updates if remove is called', () => { + const chess = new Chess() + expect(chess._positionCounts[DEFAULT_POSITION]).toBe(1) + chess.move('e4') + expect(chess._positionCounts[e4Fen]).toBe(1) + + chess.remove('e4') + chess.move('e5') + expect(chess._positionCounts['rnbqkbnr/pppp1ppp/8/4p3/8/8/PPPP1PPP/RNBQKBNR w KQkq - 0 3']).toBe(1) +}) + +test('_positionCounts - properly updates if put/remove is called but undone', () => { + const chess = new Chess() + expect(chess._positionCounts[DEFAULT_POSITION]).toBe(1) + chess.move('e4') + expect(chess._positionCounts[e4Fen]).toBe(1) + + chess.move('e5') + chess.put({type: 'p', color: 'w'}, 'e2') + chess.remove('e4') + chess.move('Nc3') + expect(chess._positionCounts['rnbqkbnr/pppp1ppp/8/4p3/8/2N5/PPPPPPPP/R1BQKBNR b KQkq - 1 2']).toBe(1) + chess.undo() + chess.undo() + expect(chess._positionCounts['rnbqkbnr/pppp1ppp/8/4p3/8/2N5/PPPPPPPP/R1BQKBNR b KQkq - 1 2']).toBe(0) + chess.move('e5') + chess.move('Nc3') + expect(chess._positionCounts['rnbqkbnr/pppp1ppp/8/4p3/8/2N5/PPPPPPPP/R1BQKBNR b KQkq - 1 2']).toBe(0) + expect(chess._positionCounts['rnbqkbnr/pppp1ppp/8/4p3/4P3/2N5/PPPP1PPP/R1BQKBNR b KQkq - 1 2']).toBe(1) +}) + diff --git a/__tests__/put.test.ts b/__tests__/put.test.ts index dffb8bef..f14ed35e 100644 --- a/__tests__/put.test.ts +++ b/__tests__/put.test.ts @@ -182,3 +182,13 @@ test('put - replacing white pawn clears black en passant square 2', () => { chess.put({ type :BISHOP, color: WHITE}, 'b5') expect(chess.moves()).not.toContain('bxc6') }); + +test('put - can be undone', () => { + const chess = new Chess() + + chess.move('e4') + chess.put({ type: PAWN, color: WHITE}, 'e2') + expect(chess.fen()).toBe('rnbqkbnr/pppppppp/8/8/4P3/8/PPPPPPPP/RNBQKBNR b KQkq - 0 1') + chess.undo({untilMove: false}) + expect(chess.fen()).toBe('rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1') +}); diff --git a/__tests__/remove.test.ts b/__tests__/remove.test.ts index 761260e5..ea4060ad 100644 --- a/__tests__/remove.test.ts +++ b/__tests__/remove.test.ts @@ -101,3 +101,13 @@ test('remove - removing white pawn clears black en passant square 2', () => { chess.remove('b5') expect(chess.moves()).not.toContain('bxc6') }); + +test('remove - can be undone', () => { + const chess = new Chess() + + chess.move('e4') + chess.remove('e4') + expect(chess.fen()).toBe('rnbqkbnr/pppppppp/8/8/8/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1') + chess.undo({untilMove: false}) + expect(chess.fen()).toBe('rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1') +}); diff --git a/package-lock.json b/package-lock.json index 540a41c1..bad827be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "BSD-2-Clause", "devDependencies": { "@types/jest": "^27.4.1", + "@types/node": "^20.3.1", "@typescript-eslint/eslint-plugin": "^5.17.0", "@typescript-eslint/parser": "^5.17.0", "eslint": "^8.12.0", @@ -1307,9 +1308,9 @@ "dev": true }, "node_modules/@types/node": { - "version": "17.0.23", - "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.23.tgz", - "integrity": "sha512-UxDxWn7dl97rKVeVS61vErvw086aCYhDLyvRQZ5Rk65rZKepaFdm53GeqXaKBuOhED4e9uWq34IC3TdSdJJ2Gw==", + "version": "20.3.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.3.1.tgz", + "integrity": "sha512-EhcH/wvidPy1WeML3TtYFGR83UzjxeWRen9V402T8aUGYsCHOmfoisV3ZSg03gAFIbLq8TnWOJ0f4cALtnSEUg==", "dev": true }, "node_modules/@types/prettier": { @@ -6849,9 +6850,9 @@ "dev": true }, "@types/node": { - "version": "17.0.23", - "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.23.tgz", - "integrity": "sha512-UxDxWn7dl97rKVeVS61vErvw086aCYhDLyvRQZ5Rk65rZKepaFdm53GeqXaKBuOhED4e9uWq34IC3TdSdJJ2Gw==", + "version": "20.3.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.3.1.tgz", + "integrity": "sha512-EhcH/wvidPy1WeML3TtYFGR83UzjxeWRen9V402T8aUGYsCHOmfoisV3ZSg03gAFIbLq8TnWOJ0f4cALtnSEUg==", "dev": true }, "@types/prettier": { diff --git a/package.json b/package.json index d2c18e2e..ed273448 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ }, "devDependencies": { "@types/jest": "^27.4.1", + "@types/node": "^20.3.1", "@typescript-eslint/eslint-plugin": "^5.17.0", "@typescript-eslint/parser": "^5.17.0", "eslint": "^8.12.0", diff --git a/src/chess.ts b/src/chess.ts index b01c6bdd..38af8761 100644 --- a/src/chess.ts +++ b/src/chess.ts @@ -67,8 +67,23 @@ type InternalMove = { flags: number } -interface History { - move: InternalMove +export interface Put { + piece: Piece, + square: Square + oldPiece?: Piece +} + +export interface Remove { + piece: Piece + square: Square +} + +interface HistoryType { + historyType: 'move' | 'put' | 'remove' + move: InternalMove | Put | Remove +} + +interface History extends HistoryType{ kings: Record turn: Color castling: Record @@ -77,6 +92,11 @@ interface History { moveNumber: number } +export type HistoryMove = { + historyType: 'move' | 'put' | 'remove' + move: Move | Put | Remove +} + export type Move = { color: Color from: Square @@ -533,11 +553,13 @@ export class Chess { private _header: Record = {} private _kings: Record = { w: EMPTY, b: EMPTY } private _epSquare = -1 + // TODO: Perhaps rename `_halfMoves` to something more descriptive, eg. `_halfMoveClock`? private _halfMoves = 0 private _moveNumber = 0 private _history: History[] = [] private _comments: Record = {} private _castling: Record = { w: 0, b: 0 } + private _positionCounts: Record = {} constructor(fen = DEFAULT_POSITION) { this.load(fen) @@ -555,6 +577,24 @@ export class Chess { this._comments = {} this._header = keepHeaders ? this._header : {} this._updateSetup(this.fen()) + /* + * Instantiate a proxy that keeps track of position occurrence counts for the purpose + * of repetition checking. The getter and setter methods automatically handle trimming + * irrelevent information from the fen, initialising new positions, and removing old + * positions from the record if their counts are reduced to 0. + */ + this._positionCounts = new Proxy({} as Record, { + get: (target, position: string) => + position === 'length' + ? Object.keys(target).length // length for unit testing + : target?.[this._trimFen(position)] || 0, + set: (target, position: string, count: number) => { + const trimmedFen = this._trimFen(position) + if (count === 0) delete target[trimmedFen] + else target[trimmedFen] = count + return true + }, + }) } removeHeader(key: string) { @@ -593,9 +633,10 @@ export class Chess { square += parseInt(piece, 10) } else { const color = piece < 'a' ? WHITE : BLACK - this.put( + this._put( { type: piece.toLowerCase() as PieceSymbol, color }, - algebraic(square) + algebraic(square), + {update: false, push: false} ) square++ } @@ -620,7 +661,13 @@ export class Chess { this._halfMoves = parseInt(tokens[4], 10) this._moveNumber = parseInt(tokens[5], 10) - this._updateSetup(this.fen()) + this._updateSetup(fen) + this._positionCounts[fen]++ + } + + private _trimFen(fen: string): string { + // remove last two fields in FEN string as they're not needed when checking for repetition + return fen.split(' ').slice(0, 4).join(' ') } fen() { @@ -750,7 +797,22 @@ export class Chess { return this._board[Ox88[square]] || false } - put({ type, color }: { type: PieceSymbol; color: Color }, square: Square) { + put({ type, color }: { type: PieceSymbol; color: Color }, square: Square): boolean { + return this._history.length === 0 + /* + * On the first turn (ie. before any moves have been made) we don't want to push the + * put to _history as we just consider it altering the starting FEN + */ + ? this._put({ type, color }, square, {update: true, push: false}) + : this._put({ type, color }, square) + } + + private _put( + { type, color }: { type: PieceSymbol; color: Color }, + square: Square, + options: { update?: boolean; push?: boolean } = {update: true, push: true} + ): boolean { + // check for piece if (SYMBOLS.indexOf(type.toLowerCase()) === -1) { return false @@ -771,30 +833,54 @@ export class Chess { return false } + if (options.push) + this._push({ piece: { type, color }, square, oldPiece: this._board[sq] }, 'put') + this._board[sq] = { type: type as PieceSymbol, color: color as Color } if (type === KING) { this._kings[color] = sq } - this._updateCastlingRights() - this._updateEnPassantSquare() - this._updateSetup(this.fen()) + if (options.update) { + this._updateCastlingRights() + this._updateEnPassantSquare() + this._updateSetup(this.fen()) + } return true } - remove(square: Square) { + remove(square: Square): Piece | false { + return this._history.length === 0 + /* + * On the first turn (ie. before any moves have been made) we don't want to push the + * remove to _history as we just consider it altering the starting FEN + */ + ? this._remove(square, {update: true, push: false}) + : this._remove(square) + } + + + private _remove( + square: Square, + options: { update?: boolean; push?: boolean } = { update: true, push: true } + ): Piece | false { const piece = this.get(square) - delete this._board[Ox88[square]] - if (piece && piece.type === KING) { - this._kings[piece.color] = EMPTY + + if (piece) { + if (options.push) this._push({piece, square}, 'remove') + delete this._board[Ox88[square]] + if (options.update) { + this._updateCastlingRights() + this._updateEnPassantSquare() + this._updateSetup(this.fen()) + } + if (piece.type === KING) { + this._kings[piece.color] = EMPTY + } } - this._updateCastlingRights() - this._updateEnPassantSquare() - this._updateSetup(this.fen()) - return piece } @@ -989,53 +1075,40 @@ export class Chess { return false } - isThreefoldRepetition() { - const moves = [] - const positions: Record = {} - let repetition = false - - while (true) { - const move = this._undoMove() - if (!move) break - moves.push(move) - } + private _getRepetitionCount() { + return this._positionCounts[this.fen()] + } - while (true) { - /* - * remove the last two fields in the FEN string, they're not needed when - * checking for draw by rep - */ - const fen = this.fen().split(' ').slice(0, 4).join(' ') + isThreefoldRepetition(): boolean { + return this._getRepetitionCount() >= 3 + } - // has the position occurred three or move times - positions[fen] = fen in positions ? positions[fen] + 1 : 1 - if (positions[fen] >= 3) { - repetition = true - } + isFivefoldRepetition(): boolean { + return this._getRepetitionCount() >= 5 + } - const move = moves.pop() + isFiftyMoveRule(): boolean { + // 50 moves per side = 100 half moves + return this._halfMoves >= 100 + } - if (!move) { - break - } else { - this._makeMove(move) - } - } + isSeventyFiveMoveRule(): boolean { + // 75 moves per side = 150 half moves + return this._halfMoves >= 150 + } - return repetition + canClaimDraw(): boolean { + return this.isThreefoldRepetition() || this.isFiftyMoveRule() } - isDraw() { - return ( - this._halfMoves >= 100 || // 50 moves per side = 100 half moves - this.isStalemate() || - this.isInsufficientMaterial() || - this.isThreefoldRepetition() - ) + isDraw({ strict = false }: { strict?: boolean } = {}): boolean { + return this.isStalemate() || this.isInsufficientMaterial() || (strict + ? this.isFivefoldRepetition() || this.isSeventyFiveMoveRule() + : this.canClaimDraw()) } - isGameOver() { - return this.isCheckmate() || this.isStalemate() || this.isDraw() + isGameOver({ strict = false }: { strict?: boolean } = {}): boolean { + return this.isCheckmate() || this.isDraw({ strict }) } moves(): string[] @@ -1358,12 +1431,13 @@ export class Chess { const prettyMove = this._makePretty(moveObj) this._makeMove(moveObj) - + this._positionCounts[prettyMove.after]++ return prettyMove } - _push(move: InternalMove) { + _push(move: History['move'], historyType: History['historyType'] = 'move'): void { this._history.push({ + historyType, move, kings: { b: this._kings.b, w: this._kings.w }, turn: this._turn, @@ -1466,23 +1540,44 @@ export class Chess { if (us === BLACK) { this._moveNumber++ } - + this._turn = them } + + undo(): Move | null + undo({ untilMove }: { untilMove: true }): Move | null + undo({ untilMove }: { untilMove: false }): HistoryMove | null + undo({ untilMove = true }: { untilMove?: boolean } = {}): + | HistoryMove + | Move + | null { + const old = this._undoMove() + if (old === null) { + return null + } - undo() { - const move = this._undoMove() - return move ? this._makePretty(move) : null + const { historyType, move } = old + if (historyType === 'move') { + const prettyMove = this._makePretty(move as InternalMove) + this._positionCounts[prettyMove.after]-- + return untilMove + ? { historyType, move: prettyMove } + : prettyMove + } else { + // old is a put/remove + return untilMove + ? this.undo() + : { historyType, move: move as Put | Remove } + } } - private _undoMove() { + private _undoMove(): History | null { const old = this._history.pop() + if (old === undefined) { return null } - - const move = old.move - + this._kings = old.kings this._turn = old.turn this._castling = old.castling @@ -1490,44 +1585,76 @@ export class Chess { this._halfMoves = old.halfMoves this._moveNumber = old.moveNumber - const us = this._turn - const them = swapColor(us) - - this._board[move.from] = this._board[move.to] - this._board[move.from].type = move.piece // to undo any promotions - delete this._board[move.to] - - if (move.captured) { - if (move.flags & BITS.EP_CAPTURE) { - // en passant capture - let index: number - if (us === BLACK) { - index = move.to - 16 + if (old.historyType === 'put') { + const putEvent = old.move as Put + if (putEvent.oldPiece) { + this._put(putEvent.oldPiece, putEvent.square, {update: true, push: false}) + } else { + this._remove(putEvent.square, {update: true, push: false}) + } + } else if (old.historyType === 'remove') { + const removeEvent = old.move as Remove + this._put(removeEvent.piece, removeEvent.square, {update: true, push: false}) + + } else if (old.historyType === 'move') { + const move = old.move as InternalMove + + const us = this._turn + const them = swapColor(us) + + this._board[move.from] = this._board[move.to] + this._board[move.from].type = move.piece // to undo any promotions + delete this._board[move.to] + + if (move.captured) { + if (move.flags & BITS.EP_CAPTURE) { + // en passant capture + let index: number + if (us === BLACK) { + index = move.to - 16 + } else { + index = move.to + 16 + } + this._board[index] = { type: PAWN, color: them } } else { - index = move.to + 16 + // regular capture + this._board[move.to] = { type: move.captured, color: them } } - this._board[index] = { type: PAWN, color: them } - } else { - // regular capture - this._board[move.to] = { type: move.captured, color: them } } - } - - if (move.flags & (BITS.KSIDE_CASTLE | BITS.QSIDE_CASTLE)) { - let castlingTo: number, castlingFrom: number - if (move.flags & BITS.KSIDE_CASTLE) { - castlingTo = move.to + 1 - castlingFrom = move.to - 1 - } else { - castlingTo = move.to - 2 - castlingFrom = move.to + 1 + + if (move.flags & (BITS.KSIDE_CASTLE | BITS.QSIDE_CASTLE)) { + let castlingTo: number, castlingFrom: number + if (move.flags & BITS.KSIDE_CASTLE) { + castlingTo = move.to + 1 + castlingFrom = move.to - 1 + } else { + castlingTo = move.to - 2 + castlingFrom = move.to + 1 + } + + this._board[castlingTo] = this._board[castlingFrom] + delete this._board[castlingFrom] } - - this._board[castlingTo] = this._board[castlingFrom] - delete this._board[castlingFrom] + } else { + throw Error(`Unexpected historyType of _history entry: ${old}`) } - return move + return old + } + + private _redoMove({historyType, move}: HistoryType): void { + // Used to redo moves undone with _undoMove (not with undo) + switch (historyType) { + case 'move': + this._makeMove(move as InternalMove) + break + case 'put': + this.put(move.piece as Piece, (move as Put).square) + break + case 'remove': + this.remove((move as Remove).square) + break + } } pgn({ @@ -1556,9 +1683,14 @@ export class Chess { result.push(newline) } - const appendComment = (moveString: string) => { - const comment = this._comments[this.fen()] - if (typeof comment !== 'undefined') { + const appendComment = ( + moveString: string, + comment: string | undefined = undefined + ): string => { + if (comment === undefined) { + comment = this._comments[this.fen()] + } + if (comment !== undefined) { const delimiter = moveString.length > 0 ? ' ' : '' moveString = `${moveString}${delimiter}{${comment}}` } @@ -1566,9 +1698,9 @@ export class Chess { } // pop all of history onto reversed_history - const reversedHistory = [] + const reversedHistory: History[] = [] while (this._history.length > 0) { - reversedHistory.push(this._undoMove()) + reversedHistory.push(this._undoMove()!) } const moves = [] @@ -1581,30 +1713,37 @@ export class Chess { // build the list of moves. a move_string looks like: "3. e3 e6" while (reversedHistory.length > 0) { - moveString = appendComment(moveString) - const move = reversedHistory.pop() + const { historyType, move } = reversedHistory.pop()! + + if (historyType === 'move') { + const moveEvent = move as InternalMove + moveString = appendComment(moveString) + + // if the position started with black to move, start PGN with #. ... + if (!this._history.length && moveEvent.color === 'b') { + const prefix = `${this._moveNumber}. ...` + // is there a comment preceding the first move? + moveString = moveString ? `${moveString} ${prefix}` : prefix + } else if (moveEvent.color === 'w') { + // store the previous generated move_string if we have one + if (moveString.length) { + moves.push(moveString) + } + moveString = this._moveNumber + '.' + } + + moveString = + moveString + ' ' + this._moveToSan(moveEvent, this._moves({ legal: true })) - // make TypeScript stop complaining about move being undefined - if (!move) { - break - } + } else if (historyType === 'put') { + const putEvent = move as Put + moveString = appendComment(moveString, `Chess.js: ${putEvent.piece.color} ${putEvent.piece.type} put on ${putEvent.square}`) - // if the position started with black to move, start PGN with #. ... - if (!this._history.length && move.color === 'b') { - const prefix = `${this._moveNumber}. ...` - // is there a comment preceding the first move? - moveString = moveString ? `${moveString} ${prefix}` : prefix - } else if (move.color === 'w') { - // store the previous generated move_string if we have one - if (moveString.length) { - moves.push(moveString) - } - moveString = this._moveNumber + '.' + } else if (historyType === 'remove') { + const removeEvent = move as Remove + moveString = appendComment(moveString, `Chess.js: ${removeEvent.piece.color} ${removeEvent.piece.type} removed from ${removeEvent.square}`) } - - moveString = - moveString + ' ' + this._moveToSan(move, this._moves({ legal: true })) - this._makeMove(move) + this._redoMove({ historyType, move }) } // are there any other leftover moves? @@ -1829,7 +1968,7 @@ export class Chess { const encodeComment = function (s: string) { s = s.replace(new RegExp(mask(newlineChar), 'g'), ' ') - return `{${toHex(s.slice(1, s.length - 1))}}` + return ` {${toHex(s)}} ` } const decodeComment = function (s: string) { @@ -1843,12 +1982,8 @@ export class Chess { .replace(headerString, '') .replace( // encode comments so they don't get deleted below - new RegExp(`({[^}]*})+?|;([^${mask(newlineChar)}]*)`, 'g'), - function (_match, bracket, semicolon) { - return bracket !== undefined - ? encodeComment(bracket) - : ' ' + encodeComment(`{${semicolon.slice(1)}}`) - } + new RegExp(`\\s*{([^}]*)}\\s*|;[^\\S\\n\\r]*([^${mask(newlineChar)}]*)[${mask(newlineChar)}]?`, 'g'), + (_match, bracket, semicolon) => encodeComment(bracket !== undefined ? bracket : semicolon) ) .replace(new RegExp(mask(newlineChar), 'g'), ' ') @@ -1878,7 +2013,24 @@ export class Chess { for (let halfMove = 0; halfMove < moves.length; halfMove++) { const comment = decodeComment(moves[halfMove]) if (comment !== undefined) { - this._comments[this.fen()] = comment + const match = comment.match( + /Chess\.js: ([wb]) ([pbnrqk]) (put|removed) (?:on|from) ([a-h][1-8])/ + ) + if (match) { + const [color, piece, event, square] = match.slice(1) + switch (event) { + case 'put': + this.put({type: piece as PieceSymbol, color: color as Color}, square as Square) + break + case 'removed': + this.remove(square as Square) + break + } + } else { + const fen = this.fen() + if (this._comments[fen]) this._comments[fen] += ' ' + comment + else this._comments[fen] = comment + } continue } @@ -1896,6 +2048,7 @@ export class Chess { // reset the end of game marker if making a valid move result = '' this._makeMove(move) + this._positionCounts[this.fen()]++ } } @@ -2223,26 +2376,49 @@ export class Chess { history({ verbose }: { verbose: true }): Move[] history({ verbose }: { verbose: false }): string[] history({ verbose }: { verbose: boolean }): string[] | Move[] - history({ verbose = false }: { verbose?: boolean } = {}) { - const reversedHistory = [] + history({ verbose, onlyMoves }: { verbose: false, onlyMoves: true }): string[] + history({ verbose, onlyMoves }: { verbose: true, onlyMoves: true }): Move[] + history({ verbose, onlyMoves }: { verbose: true, onlyMoves: false }): HistoryMove[] + history({ verbose, onlyMoves }: { verbose: false, onlyMoves: false }): never + history({ verbose, onlyMoves }: { verbose: boolean, onlyMoves: boolean }): string[] | Move[] | HistoryMove[] + history({ verbose = false, onlyMoves = true }: { verbose?: boolean, onlyMoves?: boolean } = {}): (string | Move | HistoryMove)[] { + /* + * TODO: Why does history need to replay the entire game to get the history? We are already + * storing the game history in _history. It seems the only reason we are replaying the entire + * game is just to call _makePretty on the moves, ie. to get FENs and SANs. Is this necessary? + * Perhaps we should just store this information in _history? + */ + if (!verbose && !onlyMoves) { + throw Error( + 'Unsupported options for history: { verbose: false, onlyMoves: false }.\nPlease use { verbose: true, onlyMoves: false } instead.' + ) + } + + const reversedHistory: History[]= [] const moveHistory = [] while (this._history.length > 0) { - reversedHistory.push(this._undoMove()) + reversedHistory.push(this._undoMove() as History) } - while (true) { - const move = reversedHistory.pop() - if (!move) { - break - } + while (reversedHistory.length > 0) { + const { historyType, move } = reversedHistory.pop() as History - if (verbose) { - moveHistory.push(this._makePretty(move)) - } else { - moveHistory.push(this._moveToSan(move, this._moves())) + if (onlyMoves && historyType === 'move') { + if (verbose) { + moveHistory.push(this._makePretty(move as InternalMove)) + } else { + moveHistory.push(this._moveToSan(move as InternalMove, this._moves())) + } + } else if (!onlyMoves) { + if (historyType === 'move') { + moveHistory.push({ historyType, move: this._makePretty(move as InternalMove) }) + } else { + moveHistory.push({ historyType, move: move as Put | Remove }) + } } - this._makeMove(move) + + this._redoMove({ historyType, move }) } return moveHistory @@ -2265,11 +2441,12 @@ export class Chess { copyComment(this.fen()) while (true) { - const move = reversedHistory.pop() - if (!move) { + const old = reversedHistory.pop() + if (!old) { break } - this._makeMove(move) + const { historyType, move } = old + this._redoMove({historyType, move}) copyComment(this.fen()) } this._comments = currentComments