├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── check.yml │ ├── codeql.yml │ └── publish-docs.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE.txt ├── README.md ├── data ├── headers-and-moves-on-the-same-line.pgn ├── kasparov-deep-blue-1997.pgn ├── leading-whitespace.pgn └── pathological-headers.pgn ├── dprint.json ├── eslint.config.mjs ├── examples ├── package-lock.json ├── package.json ├── src │ └── read-pgn.ts └── tsconfig.json ├── fuzz ├── .gitignore ├── README.md ├── corpus │ ├── fen │ │ ├── empty │ │ ├── endgame │ │ ├── horde │ │ ├── initial │ │ ├── scid-3check │ │ ├── scid-zh │ │ ├── too-many-empty-ranks │ │ ├── winboard-3check │ │ └── winboard-zh │ ├── pgn-comment │ │ └── comment │ ├── pgn │ │ ├── bare │ │ └── small │ ├── san │ │ ├── capture │ │ ├── castle-long │ │ ├── castle-short │ │ ├── drop │ │ ├── longest │ │ ├── move │ │ └── promotion │ └── uci │ │ ├── drop │ │ ├── move │ │ ├── null │ │ └── promotion ├── package-lock.json ├── package.json ├── src │ ├── fen.fuzz.ts │ ├── pgn-comment.fuzz.ts │ ├── pgn.fuzz.ts │ ├── san.fuzz.ts │ └── uci.fuzz.ts └── tsconfig.json ├── package-lock.json ├── package.json ├── src ├── attacks.test.ts ├── attacks.ts ├── board.test.ts ├── board.ts ├── chess.test.ts ├── chess.ts ├── compat.test.ts ├── compat.ts ├── debug.ts ├── fen.test.ts ├── fen.ts ├── index.ts ├── pgn.test.ts ├── pgn.ts ├── san.test.ts ├── san.ts ├── setup.ts ├── squareSet.test.ts ├── squareSet.ts ├── transform.test.ts ├── transform.ts ├── types.ts ├── util.test.ts ├── util.ts ├── variant.test.ts └── variant.ts └── tsconfig.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: niklasf 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: '/' 5 | schedule: 6 | interval: monthly 7 | - package-ecosystem: npm 8 | directory: '/' 9 | schedule: 10 | interval: monthly 11 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | name: Check 2 | on: [push, pull_request] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v4 8 | - uses: actions/setup-node@v4 9 | - run: npm ci 10 | - run: npm test 11 | - run: cd examples && npm ci 12 | - run: cd fuzz && npm ci && npm run compile 13 | - run: npm run lint 14 | - run: npm run check-format 15 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: CodeQL 2 | on: [push, pull_request] 3 | jobs: 4 | analyze: 5 | runs-on: ubuntu-latest 6 | permissions: 7 | actions: read 8 | contents: read 9 | security-events: write 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: github/codeql-action/init@v3 13 | with: 14 | languages: javascript 15 | - uses: github/codeql-action/analyze@v3 16 | -------------------------------------------------------------------------------- /.github/workflows/publish-docs.yml: -------------------------------------------------------------------------------- 1 | name: Publish documentation 2 | on: 3 | push: 4 | branches: 5 | - master 6 | permissions: 7 | contents: read 8 | pages: write 9 | id-token: write 10 | concurrency: 11 | group: pages 12 | cancel-in-progress: true 13 | jobs: 14 | deploy: 15 | environment: 16 | name: github-pages 17 | url: ${{ steps.deployment.outputs.page_url }} 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v4 21 | - uses: actions/setup-node@v4 22 | - run: npm ci 23 | - run: npm run doc 24 | - uses: actions/configure-pages@v4 25 | - uses: actions/upload-pages-artifact@v3 26 | with: 27 | path: docs 28 | - uses: actions/deploy-pages@v4 29 | id: deployment 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | docs/ 3 | coverage/ 4 | dist/ 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog for chessops 2 | 3 | ## v0.14.2 4 | 5 | - `chessops/pgn`: Allow and normalize en-dash and em-dash in `Result` header, 6 | result marker, and castling notation. 7 | 8 | ## v0.14.1 9 | 10 | - Set `"type": "module"` in `package.json`. 11 | 12 | ## v0.14.0 13 | 14 | - Change package layout to hybrid cjs/esm with subpath exports. 15 | - `chessops/pgn`: 16 | - Add `Node.mainlineNodes()`. 17 | - Add `node.end()`. 18 | - Add `extend()`. 19 | 20 | ## v0.13.0 21 | 22 | - `Position.fromSetup()` now only checks minimum validity requirements. 23 | Remove `FromSetupOpts`. In particular: 24 | - As before, bad `setup.castlingRights` (formerly `setup.unmovedRooks`) 25 | are silently dropped. But note changes to FEN parsing. 26 | - As before, a bad `setup.epSquare` is silently dropped. 27 | - As before, non-standard material is allowed. 28 | Use `isStandardMaterial()` if strict validation is needed. 29 | - As before, the side to move cannot be giving check. 30 | - Unlike before, some other positions involving impossibly many checkers 31 | are no longer rejected. Use `isImpossibleCheck()` if strict validation 32 | is needed. This is more consistent with the other relaxed choices above, 33 | more consistent with Lichess's behavior, and aids tree-shaking. 34 | - The FEN parser and writer now preserve syntactically valid castling rights 35 | even if there is no matching rook or king. Rename `unmovedRooks` to 36 | `castlingRights`. 37 | - Add `{Material,MaterialSide}.subtract()`. 38 | - Add `squareFromCoords()` to `chessops/util`. 39 | - Fix impossible checker validation for `Atomic`. 40 | - Fix en passant in some impossible check situations. 41 | 42 | ## v0.12.8 43 | 44 | Marked as deprecated on npm. Accidentally included semver-breaking changes. 45 | 46 | ## v0.12.7 47 | 48 | - Implement insufficient material detection for `Horde`. 49 | 50 | ## v0.12.6 51 | 52 | - Add `rookCastlesTo` to `chessops/util`. 53 | 54 | ## v0.12.5 55 | 56 | - Changes to `chessops/pgn`: 57 | - Support multiple PGN headers and move text all on the same line. 58 | - Reject `%eval` with more than 2 decimals or more than 5 digits. 59 | - Reject `%clk` and `%emt` with more than 3 decimals. 60 | 61 | ## v0.12.4 62 | 63 | - Support eval PGN comment annotations. 64 | - Fix PGN comment roundtrip by leaving additional spaces in the text. 65 | 66 | ## v0.12.3 67 | 68 | - Fix parsing X-FEN with file notation for black castling rights. Bug 69 | discovered using new fuzz-testing suite. 70 | - Improvements for `chessops/pgn`: 71 | - Add `defaultGame()`. 72 | - Add `isChildNode()`. 73 | - Add `parseComment()` and `makeComment()`. 74 | - Add `Box` for wrapping immutable context in transformations. 75 | 76 | ## v0.12.2 77 | 78 | - Detect Antichess insufficient material with two knights. 79 | 80 | ## v0.12.1 81 | 82 | - Add more aliases for PGN `Variant` headers. 83 | - Fix en passant in Antichess. 84 | 85 | ## v0.12.0 86 | 87 | - Major performance optimizations (e.g. 40% speedup in read-pgn example). 88 | - Refactor with tree-shaking in mind, for major code size improvements 89 | depending on the application. Do not find a method? It might now be 90 | a free-standing function. 91 | - Chess variants directly extend `Position` instead of `Chess`. 92 | - Remove `Board.horde()` and `Board.racingKings()`. 93 | - Rename `Castles.discardSize()` to `discardColor()`. 94 | 95 | ## v0.11.0 96 | 97 | - Introduce `chessops/pgn` module. 98 | - Remove `chessops/hash` module. 99 | - Fix capturing promoted Crazyhouse pieces. 100 | - Rename `Material.count()` and `MaterialSide.count()` to `size()`. 101 | Add `Material.count(role)`. 102 | - Add `Position.isStandardMaterial()`. 103 | - Remove `FenOpts.shredder`. 104 | - Track promoted pieces only in Crazyhouse. Remove `FenOpts.promoted`. 105 | - Let `parseSan()` require the original file for pawn captures 106 | (good: `exd5`, bad: `d5` if capturing). 107 | - Target ES2018. 108 | 109 | ## v0.10.5 110 | 111 | - Check that en passant square and checkers are not in conflict. 112 | - Accept castling flags in FEN in any order, duplicate flags, but no more 113 | than two distinct castling rights per side. 114 | - Accept multiple spaces and underscores as FEN field seperators. 115 | 116 | ## v0.10.4 117 | 118 | - Add `.js` ending to relative imports (required for ES modules). 119 | - Introduce `FromSetupOpts` with `ignoreImpossibleCheck`. 120 | 121 | ## v0.10.3 122 | 123 | - Fix `ThreeCheck` not updating remaining checks. 124 | 125 | ## v0.10.2 126 | 127 | - Fix `Atomic` position validation, where the remaining king is attacked, but 128 | the other king has exploded. 129 | 130 | ## v0.10.1 131 | 132 | - Export `RacingKings` from `chessops/variant`. 133 | - Export `FenOpts` from `chessops/fen`. 134 | 135 | ## v0.10.0 136 | 137 | - Renamed `lichessVariantRules()` to `lichessRules()`, added the inverse 138 | function `lichessVariant()`. 139 | 140 | ## v0.9.0 141 | 142 | - Now built as ES module (instead of CommonJS). 143 | - Made `kingCastlesTo()` public. 144 | 145 | ## v0.8.1 146 | 147 | - Moved `FILES` and `RANKS` from `chessops/util` to `chessops/types`, renamed 148 | to `FILE_NAMES` and `RANK_NAMES`, and added corresponding `FileName` and 149 | `RankName` string literal types. 150 | - Also reject positions where a checker is aligned with the en passant square 151 | and the king as `IllegalSetup.ImpossibleCheck`. 152 | 153 | ## v0.7.4 154 | 155 | - Added `IllegalSetup.ImpossibleCheck` and will now reject setups with too many 156 | checkers or aligned sliding checkers. These positions are impossible to reach 157 | and Stockfish does not work properly on them. 158 | - Added `equals()` to most classes, `Board.equalsIgnorePromoted()`, and 159 | `Position.equalsIgnoreMoves()`. 160 | 161 | ## v0.7.3 162 | 163 | - Fixed `parseSan()` to properly interpret moves like `bxc6` as a pawn capture 164 | instead of a bishop move. Lowercase piece letters are no longer accepted, 165 | e.g., `nf3` must be `Nf3`. 166 | - Fixed `Position.isLegal()` was not accepting the king-moves-two-squares 167 | encoding for castling castling moves. 168 | 169 | ## v0.7.2 170 | 171 | - Fixed insufficient material with same-color bishops on both sides. 172 | 173 | ## v0.7.1 174 | 175 | - Fixed `parseSan` with regard to pawn captures, promotions and some 176 | disambiguated moves. 177 | 178 | ## v0.7.0 179 | 180 | - Added `parseSan()`. 181 | - Added `chessgroundMove()` compatibility. 182 | - Added `FILES` and `RANKS` constants. 183 | - Fix spelling of export: `transfrom()` -> `transform()`. 184 | - Added `{MaterialSide,Material}.{fromBoard,add}()`. 185 | - Clamp move counters in FEN export for guaranteed reparsability. 186 | - Reject promotions not on backrank. 187 | - Micro-optimizations. 188 | 189 | ## v0.6.0 190 | 191 | - Updated `chessgroundDests()` for chessground 7.8 compatibility. 192 | - Added `chessgroundDests(pos, {chess960: true})` to generate only 193 | Chess960-style castling moves. 194 | - Renamed `scalachessId()` to `scalachessCharPair()`. 195 | 196 | ## v0.5.0 197 | 198 | - Removed `util.squareDist()`. 199 | - Further optimized initialization. 200 | - Packaged source map. 201 | 202 | ## v0.4.2 203 | 204 | - Performance optimizations, significantly faster initilization. 205 | - Added `SquareSet.withoutFirst()`. 206 | - Bumped target to ES2016. 207 | 208 | ## v0.4.1 209 | 210 | - Added `lichessVariantRules()` compatibility. 211 | 212 | ## v0.4.0 213 | 214 | - Renamed `Uci` to `Move`, `UciMove` to `NormalMove`, `UciDrop` to `DropMove`, 215 | and `isMove()` to `isNormal()`. 216 | - Renamed `uciCharPair()` to `scalachessId()`. 217 | - All `ctx` parameters are now optional. 218 | - Added `index` module with re-exports. 219 | 220 | ## v0.3.6 221 | 222 | - Fixed alternative queenside castling moves (king moved by two squares instead 223 | of onto rook). These moves were not correctly classified, normalized or 224 | played. 225 | 226 | ## v0.3.5 227 | 228 | - Added `compat` module for 229 | [chessground](https://github.com/ornicar/chessground) and 230 | [scalachess](https://github.com/ornicar/scalachess) interoperability. 231 | - Added `Position.castlingSide()`. 232 | - Added `Position.normalizeMove()`. 233 | - Overloaded `parseSquare()` for known valid `SquareName`. 234 | 235 | ## v0.3.4 236 | 237 | - Fixed castling paths in `Castles.default()` and `Chess.default()`, leading 238 | to illegal king moves. 239 | 240 | ## v0.3.3 241 | 242 | - Fixed Racing Kings move generation with king near goal. 243 | - Pawn drops in Crazyhouse reset `pos.halfmoves`. 244 | 245 | ## v0.3.2 246 | 247 | - Fixed SAN of en passant captures. 248 | 249 | ## v0.3.1 250 | 251 | - Optimize SAN disambiguation by adding a fast path for unambiguous moves. 252 | 253 | ## v0.3.0 254 | 255 | - Renamed `san.makeVariationSan()` to `san.makeSanVariation()`. 256 | - Fixed SAN disambiguation on b and c file. 257 | 258 | ## v0.2.0 259 | 260 | - Fixed check from a1. 261 | - Fixed insufficient material with same-color bishops. 262 | - Fixed Crazyhouse validation and limit pocket size. 263 | - Fixed `Position.fromSetup()` entangles position with setup. 264 | - Made `Position.rules()` a read-only property `Position.rules`. 265 | - Removed `SquareSet.subsets()`. 266 | - Removed `utils.strRepeat()`. 267 | - Micro optimizations and misc non-functional tweaks. 268 | 269 | ## v0.1.0 270 | 271 | - First release. 272 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # chessops 2 | 3 | [![Test](https://github.com/niklasf/chessops/workflows/Test/badge.svg)](https://github.com/niklasf/chessops/actions) 4 | [![npm](https://img.shields.io/npm/v/chessops)](https://www.npmjs.com/package/chessops) 5 | 6 | Chess and chess variant rules and operations in TypeScript. 7 | 8 | ## Documentation 9 | 10 | [View TypeDoc](https://niklasf.github.io/chessops/) 11 | 12 | ## Features 13 | 14 | - [Read and write FEN](https://niklasf.github.io/chessops/modules/fen.html) 15 | - Vocabulary 16 | - `Square` 17 | - `SquareSet` (implemented as bitboards) 18 | - `Color` 19 | - `Role` (piece type) 20 | - `Piece` (`Role` and `Color`) 21 | - `Board` (map of piece positions) 22 | - `Castles` 23 | - `Setup` (a not necessarily legal position) 24 | - `Position` (base class for legal positions, `Chess` is a concrete implementation) 25 | - [Variant rules](https://niklasf.github.io/chessops/modules/variant.html): 26 | Standard chess, Crazyhouse, King of the Hill, Three-check, 27 | Antichess, Atomic, Horde, Racing Kings 28 | - Move making 29 | - Legal move and drop move generation 30 | - Game end and outcome 31 | - Insufficient material 32 | - Setup validation 33 | - Supports Chess960 34 | - [Attacks and rays](https://niklasf.github.io/chessops/modules/attacks.html) 35 | using Hyperbola Quintessence (faster to initialize than Magic Bitboards) 36 | - Read and write UCI move notation 37 | - [Read and write SAN](https://niklasf.github.io/chessops/modules/san.html) 38 | - [Read and write PGN](https://niklasf.github.io/chessops/modules/pgn.html) 39 | - Parser supports asynchronous streaming 40 | - Game tree model 41 | - Transform game tree to augment nodes with arbitrary user data 42 | - Parse comments with evaluations, clocks and shapes 43 | - [Transformations](https://niklasf.github.io/chessops/modules/transform.html): Mirroring and rotating 44 | - [Compatibility](https://niklasf.github.io/chessops/modules/compat.html): 45 | [chessground](https://github.com/ornicar/chessground) and 46 | [scalachess](https://github.com/ornicar/scalachess) 47 | 48 | ## Example 49 | 50 | ```javascript 51 | import { Chess } from 'chessops/chess'; 52 | import { parseFen } from 'chessops/fen'; 53 | 54 | const setup = parseFen('r1bqkbnr/ppp2Qpp/2np4/4p3/2B1P3/8/PPPP1PPP/RNB1K1NR b KQkq - 0 4').unwrap(); 55 | const pos = Chess.fromSetup(setup).unwrap(); 56 | console.assert(pos.isCheckmate()); 57 | ``` 58 | 59 | ## License 60 | 61 | chessops is licensed under the GNU General Public License 3 or any later 62 | version at your choice. See LICENSE.txt for details. 63 | -------------------------------------------------------------------------------- /data/headers-and-moves-on-the-same-line.pgn: -------------------------------------------------------------------------------- 1 | [Variant "Antichess"] 1.e3 e6 2.b4 Bxb4 3.Qg4 { Header on the same line as moves } 2 | 3 | [Variant "Antichess"] 4 | 1.e3 e6 2.b4 Bxb4 3.Qg4 { Header followed by a separate line containing a sequence of moves} 5 | 6 | [Variant "Antichess"] 7 | 8 | 1.e3 e6 2.b4 Bxb4 3.Qg4 { Header followed by a blank line and a line containing a sequence of moves } 9 | -------------------------------------------------------------------------------- /data/kasparov-deep-blue-1997.pgn: -------------------------------------------------------------------------------- 1 | [Event "IBM Man-Machine, New York USA"] 2 | [Site "01"] 3 | [Date "1997.??.??"] 4 | [EventDate "?"] 5 | [Round "?"] 6 | [Result "1-0"] 7 | [White "Garry Kasparov"] 8 | [Black "Deep Blue (Computer)"] 9 | [ECO "A06"] 10 | [WhiteElo "?"] 11 | [BlackElo "?"] 12 | [PlyCount "89"] 13 | 14 | 1.Nf3 d5 2.g3 Bg4 3.b3 Nd7 4.Bb2 e6 5.Bg2 Ngf6 6.O-O c6 7.d3 15 | Bd6 8.Nbd2 O-O 9.h3 Bh5 10.e3 h6 11.Qe1 Qa5 12.a3 Bc7 13.Nh4 16 | g5 14.Nhf3 e5 15.e4 Rfe8 16.Nh2 Qb6 17.Qc1 a5 18.Re1 Bd6 17 | 19.Ndf1 dxe4 20.dxe4 Bc5 21.Ne3 Rad8 22.Nhf1 g4 23.hxg4 Nxg4 18 | 24.f3 Nxe3 25.Nxe3 Be7 26.Kh1 Bg5 27.Re2 a4 28.b4 f5 29.exf5 19 | e4 30.f4 Bxe2 31.fxg5 Ne5 32.g6 Bf3 33.Bc3 Qb5 34.Qf1 Qxf1+ 20 | 35.Rxf1 h5 36.Kg1 Kf8 37.Bh3 b5 38.Kf2 Kg7 39.g4 Kh6 40.Rg1 21 | hxg4 41.Bxg4 Bxg4 42.Nxg4+ Nxg4+ 43.Rxg4 Rd5 44.f6 Rd1 45.g7 22 | 1-0 23 | 24 | [Event "IBM Man-Machine, New York USA"] 25 | [Site "02"] 26 | [Date "1997.??.??"] 27 | [EventDate "?"] 28 | [Round "?"] 29 | [Result "1-0"] 30 | [White "Deep Blue (Computer)"] 31 | [Black "Garry Kasparov"] 32 | [ECO "C93"] 33 | [WhiteElo "?"] 34 | [BlackElo "?"] 35 | [PlyCount "89"] 36 | 37 | 1.e4 e5 2.Nf3 Nc6 3.Bb5 a6 4.Ba4 Nf6 5.O-O Be7 6.Re1 b5 7.Bb3 38 | d6 8.c3 O-O 9.h3 h6 10.d4 Re8 11.Nbd2 Bf8 12.Nf1 Bd7 13.Ng3 39 | Na5 14.Bc2 c5 15.b3 Nc6 16.d5 Ne7 17.Be3 Ng6 18.Qd2 Nh7 19.a4 40 | Nh4 20.Nxh4 Qxh4 21.Qe2 Qd8 22.b4 Qc7 23.Rec1 c4 24.Ra3 Rec8 41 | 25.Rca1 Qd8 26.f4 Nf6 27.fxe5 dxe5 28.Qf1 Ne8 29.Qf2 Nd6 42 | 30.Bb6 Qe8 31.R3a2 Be7 32.Bc5 Bf8 33.Nf5 Bxf5 34.exf5 f6 43 | 35.Bxd6 Bxd6 36.axb5 axb5 37.Be4 Rxa2 38.Qxa2 Qd7 39.Qa7 Rc7 44 | 40.Qb6 Rb7 41.Ra8+ Kf7 42.Qa6 Qc7 43.Qc6 Qb6+ 44.Kf1 Rb8 45 | 45.Ra6 1-0 46 | 47 | [Event "IBM Man-Machine, New York USA"] 48 | [Site "03"] 49 | [Date "1997.??.??"] 50 | [EventDate "?"] 51 | [Round "?"] 52 | [Result "1/2-1/2"] 53 | [White "Garry Kasparov"] 54 | [Black "Deep Blue (Computer)"] 55 | [ECO "A00"] 56 | [WhiteElo "?"] 57 | [BlackElo "?"] 58 | [PlyCount "95"] 59 | 60 | 1.d3 e5 2.Nf3 Nc6 3.c4 Nf6 4.a3 d6 5.Nc3 Be7 6.g3 O-O 7.Bg2 61 | Be6 8.O-O Qd7 9.Ng5 Bf5 10.e4 Bg4 11.f3 Bh5 12.Nh3 Nd4 13.Nf2 62 | h6 14.Be3 c5 15.b4 b6 16.Rb1 Kh8 17.Rb2 a6 18.bxc5 bxc5 19.Bh3 63 | Qc7 20.Bg4 Bg6 21.f4 exf4 22.gxf4 Qa5 23.Bd2 Qxa3 24.Ra2 Qb3 64 | 25.f5 Qxd1 26.Bxd1 Bh7 27.Nh3 Rfb8 28.Nf4 Bd8 29.Nfd5 Nc6 65 | 30.Bf4 Ne5 31.Ba4 Nxd5 32.Nxd5 a5 33.Bb5 Ra7 34.Kg2 g5 66 | 35.Bxe5+ dxe5 36.f6 Bg6 37.h4 gxh4 38.Kh3 Kg8 39.Kxh4 Kh7 67 | 40.Kg4 Bc7 41.Nxc7 Rxc7 42.Rxa5 Rd8 43.Rf3 Kh8 44.Kh4 Kg8 68 | 45.Ra3 Kh8 46.Ra6 Kh7 47.Ra3 Kh8 48.Ra6 1/2-1/2 69 | 70 | [Event "IBM Man-Machine, New York USA"] 71 | [Site "04"] 72 | [Date "1997.??.??"] 73 | [EventDate "?"] 74 | [Round "?"] 75 | [Result "1/2-1/2"] 76 | [White "Deep Blue (Computer)"] 77 | [Black "Garry Kasparov"] 78 | [ECO "B10"] 79 | [WhiteElo "?"] 80 | [BlackElo "?"] 81 | [PlyCount "111"] 82 | 83 | 1.e4 c6 2.d4 d6 3.Nf3 Nf6 4.Nc3 Bg4 5.h3 Bh5 6.Bd3 e6 7.Qe2 d5 84 | 8.Bg5 Be7 9.e5 Nfd7 10.Bxe7 Qxe7 11.g4 Bg6 12.Bxg6 hxg6 13.h4 85 | Na6 14.O-O-O O-O-O 15.Rdg1 Nc7 16.Kb1 f6 17.exf6 Qxf6 18.Rg3 86 | Rde8 19.Re1 Rhf8 20.Nd1 e5 21.dxe5 Qf4 22.a3 Ne6 23.Nc3 Ndc5 87 | 24.b4 Nd7 25.Qd3 Qf7 26.b5 Ndc5 27.Qe3 Qf4 28.bxc6 bxc6 29.Rd1 88 | Kc7 30.Ka1 Qxe3 31.fxe3 Rf7 32.Rh3 Ref8 33.Nd4 Rf2 34.Rb1 Rg2 89 | 35.Nce2 Rxg4 36.Nxe6+ Nxe6 37.Nd4 Nxd4 38.exd4 Rxd4 39.Rg1 Rc4 90 | 40.Rxg6 Rxc2 41.Rxg7+ Kb6 42.Rb3+ Kc5 43.Rxa7 Rf1+ 44.Rb1 Rff2 91 | 45.Rb4 Rc1+ 46.Rb1 Rcc2 47.Rb4 Rc1+ 48.Rb1 Rxb1+ 49.Kxb1 Re2 92 | 50.Re7 Rh2 51.Rh7 Kc4 52.Rc7 c5 53.e6 Rxh4 54.e7 Re4 55.a4 Kb3 93 | 56.Kc1 1/2-1/2 94 | 95 | [Event "IBM Man-Machine, New York USA"] 96 | [Site "05"] 97 | [Date "1997.??.??"] 98 | [EventDate "?"] 99 | [Round "?"] 100 | [Result "1/2-1/2"] 101 | [White "Garry Kasparov"] 102 | [Black "Deep Blue (Computer)"] 103 | [ECO "A07"] 104 | [WhiteElo "?"] 105 | [BlackElo "?"] 106 | [PlyCount "98"] 107 | 108 | 1.Nf3 d5 2.g3 Bg4 3.Bg2 Nd7 4.h3 Bxf3 5.Bxf3 c6 6.d3 e6 7.e4 109 | Ne5 8.Bg2 dxe4 9.Bxe4 Nf6 10.Bg2 Bb4+ 11.Nd2 h5 12.Qe2 Qc7 110 | 13.c3 Be7 14.d4 Ng6 15.h4 e5 16.Nf3 exd4 17.Nxd4 O-O-O 18.Bg5 111 | Ng4 19.O-O-O Rhe8 20.Qc2 Kb8 21.Kb1 Bxg5 22.hxg5 N6e5 23.Rhe1 112 | c5 24.Nf3 Rxd1+ 25.Rxd1 Nc4 26.Qa4 Rd8 27.Re1 Nb6 28.Qc2 Qd6 113 | 29.c4 Qg6 30.Qxg6 fxg6 31.b3 Nxf2 32.Re6 Kc7 33.Rxg6 Rd7 114 | 34.Nh4 Nc8 35.Bd5 Nd6 36.Re6 Nb5 37.cxb5 Rxd5 38.Rg6 Rd7 115 | 39.Nf5 Ne4 40.Nxg7 Rd1+ 41.Kc2 Rd2+ 42.Kc1 Rxa2 43.Nxh5 Nd2 116 | 44.Nf4 Nxb3+ 45.Kb1 Rd2 46.Re6 c4 47.Re3 Kb6 48.g6 Kxb5 49.g7 117 | Kb4 1/2-1/2 118 | 119 | [Event "IBM Man-Machine, New York USA"] 120 | [Site "06"] 121 | [Date "1997.??.??"] 122 | [EventDate "?"] 123 | [Round "?"] 124 | [Result "1-0"] 125 | [White "Deep Blue (Computer)"] 126 | [Black "Garry Kasparov"] 127 | [ECO "B17"] 128 | [WhiteElo "?"] 129 | [BlackElo "?"] 130 | [PlyCount "37"] 131 | 132 | 1.e4 c6 2.d4 d5 3.Nc3 dxe4 4.Nxe4 Nd7 5.Ng5 Ngf6 6.Bd3 e6 133 | 7.N1f3 h6 8.Nxe6 Qe7 9.O-O fxe6 10.Bg6+ Kd8 11.Bf4 b5 12.a4 134 | Bb7 13.Re1 Nd5 14.Bg3 Kc8 15.axb5 cxb5 16.Qd3 Bc6 17.Bf5 exf5 135 | 18.Rxe7 Bxe7 19.c4 1-0 136 | -------------------------------------------------------------------------------- /data/leading-whitespace.pgn: -------------------------------------------------------------------------------- 1 | [Variant "Standard"] 2 | 3 | 1. e4 e5 2. Nf3 Nc6 3. Bb5 { Leading whitespace, header followed, blank line, move sequence } 4 | 5 | [Variant "Standard"] 1. e4 e5 2. Nf3 Nc6 3. Bb5 { leading whitespace, header, move sequence } 6 | 7 | [Variant "Standard"] 8 | 1. e4 e5 2. Nf3 Nc6 3. Bb5 { leading whitespace, header, new line, move sequence } 9 | 10 | 11 | 1. e4 e5 2. Nf3 Nc6 3. Bb5 { leading whitespace, no header, move sequence } 12 | -------------------------------------------------------------------------------- /data/pathological-headers.pgn: -------------------------------------------------------------------------------- 1 | [A "b\""] [B "b\""] [C "A]]"] [D "A]]["] [E "\"A]][\""] [F "\"A]][\"\\"] [G "\"]"] 2 | -------------------------------------------------------------------------------- /dprint.json: -------------------------------------------------------------------------------- 1 | { 2 | "lineWidth": 120, 3 | "typescript": { 4 | "quoteStyle": "preferSingle" 5 | }, 6 | "excludes": [ 7 | "**/node_modules", 8 | "**/*-lock.json", 9 | "docs/", 10 | "coverage/", 11 | "*.js.map", 12 | "*.d.ts", 13 | "*.js" 14 | ], 15 | "plugins": [ 16 | "https://plugins.dprint.dev/typescript-0.88.10.wasm", 17 | "https://plugins.dprint.dev/json-0.19.1.wasm", 18 | "https://plugins.dprint.dev/markdown-0.16.3.wasm" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import typescriptEslint from '@typescript-eslint/eslint-plugin'; 2 | import tsParser from '@typescript-eslint/parser'; 3 | 4 | export default [ 5 | { ignores: ['dist'] }, 6 | { 7 | files: ['**/*.ts'], 8 | plugins: { '@typescript-eslint': typescriptEslint }, 9 | languageOptions: { 10 | parser: tsParser, 11 | ecmaVersion: 5, 12 | sourceType: 'module', 13 | parserOptions: { 14 | projectService: true, 15 | tsconfigRootDir: import.meta.dirname, 16 | }, 17 | }, 18 | rules: { 19 | eqeqeq: ['error', 'smart'], 20 | 'linebreak-style': ['error', 'unix'], 21 | 'no-duplicate-imports': 'error', 22 | 'prefer-const': 'warn', 23 | '@typescript-eslint/no-empty-function': ['error', { 24 | allow: ['constructors'], 25 | }], 26 | '@typescript-eslint/no-explicit-any': 'off', 27 | '@typescript-eslint/no-non-null-assertion': 'off', 28 | '@typescript-eslint/no-this-alias': 'off', 29 | '@typescript-eslint/no-empty-function': 'off', 30 | '@typescript-eslint/no-unused-vars': ['warn', { 31 | argsIgnorePattern: '^_', 32 | }], 33 | '@typescript-eslint/no-unnecessary-condition': 'error', 34 | }, 35 | }, 36 | ]; 37 | -------------------------------------------------------------------------------- /examples/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chessops-examples", 3 | "lockfileVersion": 3, 4 | "requires": true, 5 | "packages": { 6 | "": { 7 | "name": "chessops-examples", 8 | "license": "GPL-3.0-or-later", 9 | "dependencies": { 10 | "chessops": "file:.." 11 | }, 12 | "devDependencies": { 13 | "esbuild": "^0.20", 14 | "typescript": "^5" 15 | } 16 | }, 17 | "..": { 18 | "version": "0.14.1", 19 | "license": "GPL-3.0-or-later", 20 | "dependencies": { 21 | "@badrap/result": "^0.2" 22 | }, 23 | "devDependencies": { 24 | "@jest/globals": "^29", 25 | "@typescript-eslint/eslint-plugin": "^7", 26 | "@typescript-eslint/parser": "^7", 27 | "dprint": "^0.45", 28 | "eslint": "^8", 29 | "jest": "^29", 30 | "ts-jest": "^29", 31 | "typedoc": "^0.25", 32 | "typescript": "^5" 33 | }, 34 | "funding": { 35 | "url": "https://github.com/sponsors/niklasf" 36 | } 37 | }, 38 | "node_modules/@esbuild/aix-ppc64": { 39 | "version": "0.20.2", 40 | "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", 41 | "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", 42 | "cpu": [ 43 | "ppc64" 44 | ], 45 | "dev": true, 46 | "optional": true, 47 | "os": [ 48 | "aix" 49 | ], 50 | "engines": { 51 | "node": ">=12" 52 | } 53 | }, 54 | "node_modules/@esbuild/android-arm": { 55 | "version": "0.20.2", 56 | "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", 57 | "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", 58 | "cpu": [ 59 | "arm" 60 | ], 61 | "dev": true, 62 | "optional": true, 63 | "os": [ 64 | "android" 65 | ], 66 | "engines": { 67 | "node": ">=12" 68 | } 69 | }, 70 | "node_modules/@esbuild/android-arm64": { 71 | "version": "0.20.2", 72 | "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", 73 | "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", 74 | "cpu": [ 75 | "arm64" 76 | ], 77 | "dev": true, 78 | "optional": true, 79 | "os": [ 80 | "android" 81 | ], 82 | "engines": { 83 | "node": ">=12" 84 | } 85 | }, 86 | "node_modules/@esbuild/android-x64": { 87 | "version": "0.20.2", 88 | "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", 89 | "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", 90 | "cpu": [ 91 | "x64" 92 | ], 93 | "dev": true, 94 | "optional": true, 95 | "os": [ 96 | "android" 97 | ], 98 | "engines": { 99 | "node": ">=12" 100 | } 101 | }, 102 | "node_modules/@esbuild/darwin-arm64": { 103 | "version": "0.20.2", 104 | "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", 105 | "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", 106 | "cpu": [ 107 | "arm64" 108 | ], 109 | "dev": true, 110 | "optional": true, 111 | "os": [ 112 | "darwin" 113 | ], 114 | "engines": { 115 | "node": ">=12" 116 | } 117 | }, 118 | "node_modules/@esbuild/darwin-x64": { 119 | "version": "0.20.2", 120 | "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", 121 | "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", 122 | "cpu": [ 123 | "x64" 124 | ], 125 | "dev": true, 126 | "optional": true, 127 | "os": [ 128 | "darwin" 129 | ], 130 | "engines": { 131 | "node": ">=12" 132 | } 133 | }, 134 | "node_modules/@esbuild/freebsd-arm64": { 135 | "version": "0.20.2", 136 | "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", 137 | "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", 138 | "cpu": [ 139 | "arm64" 140 | ], 141 | "dev": true, 142 | "optional": true, 143 | "os": [ 144 | "freebsd" 145 | ], 146 | "engines": { 147 | "node": ">=12" 148 | } 149 | }, 150 | "node_modules/@esbuild/freebsd-x64": { 151 | "version": "0.20.2", 152 | "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", 153 | "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", 154 | "cpu": [ 155 | "x64" 156 | ], 157 | "dev": true, 158 | "optional": true, 159 | "os": [ 160 | "freebsd" 161 | ], 162 | "engines": { 163 | "node": ">=12" 164 | } 165 | }, 166 | "node_modules/@esbuild/linux-arm": { 167 | "version": "0.20.2", 168 | "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", 169 | "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", 170 | "cpu": [ 171 | "arm" 172 | ], 173 | "dev": true, 174 | "optional": true, 175 | "os": [ 176 | "linux" 177 | ], 178 | "engines": { 179 | "node": ">=12" 180 | } 181 | }, 182 | "node_modules/@esbuild/linux-arm64": { 183 | "version": "0.20.2", 184 | "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", 185 | "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", 186 | "cpu": [ 187 | "arm64" 188 | ], 189 | "dev": true, 190 | "optional": true, 191 | "os": [ 192 | "linux" 193 | ], 194 | "engines": { 195 | "node": ">=12" 196 | } 197 | }, 198 | "node_modules/@esbuild/linux-ia32": { 199 | "version": "0.20.2", 200 | "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", 201 | "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", 202 | "cpu": [ 203 | "ia32" 204 | ], 205 | "dev": true, 206 | "optional": true, 207 | "os": [ 208 | "linux" 209 | ], 210 | "engines": { 211 | "node": ">=12" 212 | } 213 | }, 214 | "node_modules/@esbuild/linux-loong64": { 215 | "version": "0.20.2", 216 | "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", 217 | "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", 218 | "cpu": [ 219 | "loong64" 220 | ], 221 | "dev": true, 222 | "optional": true, 223 | "os": [ 224 | "linux" 225 | ], 226 | "engines": { 227 | "node": ">=12" 228 | } 229 | }, 230 | "node_modules/@esbuild/linux-mips64el": { 231 | "version": "0.20.2", 232 | "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", 233 | "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", 234 | "cpu": [ 235 | "mips64el" 236 | ], 237 | "dev": true, 238 | "optional": true, 239 | "os": [ 240 | "linux" 241 | ], 242 | "engines": { 243 | "node": ">=12" 244 | } 245 | }, 246 | "node_modules/@esbuild/linux-ppc64": { 247 | "version": "0.20.2", 248 | "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", 249 | "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", 250 | "cpu": [ 251 | "ppc64" 252 | ], 253 | "dev": true, 254 | "optional": true, 255 | "os": [ 256 | "linux" 257 | ], 258 | "engines": { 259 | "node": ">=12" 260 | } 261 | }, 262 | "node_modules/@esbuild/linux-riscv64": { 263 | "version": "0.20.2", 264 | "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", 265 | "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", 266 | "cpu": [ 267 | "riscv64" 268 | ], 269 | "dev": true, 270 | "optional": true, 271 | "os": [ 272 | "linux" 273 | ], 274 | "engines": { 275 | "node": ">=12" 276 | } 277 | }, 278 | "node_modules/@esbuild/linux-s390x": { 279 | "version": "0.20.2", 280 | "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", 281 | "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", 282 | "cpu": [ 283 | "s390x" 284 | ], 285 | "dev": true, 286 | "optional": true, 287 | "os": [ 288 | "linux" 289 | ], 290 | "engines": { 291 | "node": ">=12" 292 | } 293 | }, 294 | "node_modules/@esbuild/linux-x64": { 295 | "version": "0.20.2", 296 | "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", 297 | "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", 298 | "cpu": [ 299 | "x64" 300 | ], 301 | "dev": true, 302 | "optional": true, 303 | "os": [ 304 | "linux" 305 | ], 306 | "engines": { 307 | "node": ">=12" 308 | } 309 | }, 310 | "node_modules/@esbuild/netbsd-x64": { 311 | "version": "0.20.2", 312 | "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", 313 | "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", 314 | "cpu": [ 315 | "x64" 316 | ], 317 | "dev": true, 318 | "optional": true, 319 | "os": [ 320 | "netbsd" 321 | ], 322 | "engines": { 323 | "node": ">=12" 324 | } 325 | }, 326 | "node_modules/@esbuild/openbsd-x64": { 327 | "version": "0.20.2", 328 | "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", 329 | "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", 330 | "cpu": [ 331 | "x64" 332 | ], 333 | "dev": true, 334 | "optional": true, 335 | "os": [ 336 | "openbsd" 337 | ], 338 | "engines": { 339 | "node": ">=12" 340 | } 341 | }, 342 | "node_modules/@esbuild/sunos-x64": { 343 | "version": "0.20.2", 344 | "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", 345 | "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", 346 | "cpu": [ 347 | "x64" 348 | ], 349 | "dev": true, 350 | "optional": true, 351 | "os": [ 352 | "sunos" 353 | ], 354 | "engines": { 355 | "node": ">=12" 356 | } 357 | }, 358 | "node_modules/@esbuild/win32-arm64": { 359 | "version": "0.20.2", 360 | "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", 361 | "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", 362 | "cpu": [ 363 | "arm64" 364 | ], 365 | "dev": true, 366 | "optional": true, 367 | "os": [ 368 | "win32" 369 | ], 370 | "engines": { 371 | "node": ">=12" 372 | } 373 | }, 374 | "node_modules/@esbuild/win32-ia32": { 375 | "version": "0.20.2", 376 | "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", 377 | "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", 378 | "cpu": [ 379 | "ia32" 380 | ], 381 | "dev": true, 382 | "optional": true, 383 | "os": [ 384 | "win32" 385 | ], 386 | "engines": { 387 | "node": ">=12" 388 | } 389 | }, 390 | "node_modules/@esbuild/win32-x64": { 391 | "version": "0.20.2", 392 | "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", 393 | "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", 394 | "cpu": [ 395 | "x64" 396 | ], 397 | "dev": true, 398 | "optional": true, 399 | "os": [ 400 | "win32" 401 | ], 402 | "engines": { 403 | "node": ">=12" 404 | } 405 | }, 406 | "node_modules/chessops": { 407 | "resolved": "..", 408 | "link": true 409 | }, 410 | "node_modules/esbuild": { 411 | "version": "0.20.2", 412 | "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", 413 | "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", 414 | "dev": true, 415 | "hasInstallScript": true, 416 | "bin": { 417 | "esbuild": "bin/esbuild" 418 | }, 419 | "engines": { 420 | "node": ">=12" 421 | }, 422 | "optionalDependencies": { 423 | "@esbuild/aix-ppc64": "0.20.2", 424 | "@esbuild/android-arm": "0.20.2", 425 | "@esbuild/android-arm64": "0.20.2", 426 | "@esbuild/android-x64": "0.20.2", 427 | "@esbuild/darwin-arm64": "0.20.2", 428 | "@esbuild/darwin-x64": "0.20.2", 429 | "@esbuild/freebsd-arm64": "0.20.2", 430 | "@esbuild/freebsd-x64": "0.20.2", 431 | "@esbuild/linux-arm": "0.20.2", 432 | "@esbuild/linux-arm64": "0.20.2", 433 | "@esbuild/linux-ia32": "0.20.2", 434 | "@esbuild/linux-loong64": "0.20.2", 435 | "@esbuild/linux-mips64el": "0.20.2", 436 | "@esbuild/linux-ppc64": "0.20.2", 437 | "@esbuild/linux-riscv64": "0.20.2", 438 | "@esbuild/linux-s390x": "0.20.2", 439 | "@esbuild/linux-x64": "0.20.2", 440 | "@esbuild/netbsd-x64": "0.20.2", 441 | "@esbuild/openbsd-x64": "0.20.2", 442 | "@esbuild/sunos-x64": "0.20.2", 443 | "@esbuild/win32-arm64": "0.20.2", 444 | "@esbuild/win32-ia32": "0.20.2", 445 | "@esbuild/win32-x64": "0.20.2" 446 | } 447 | }, 448 | "node_modules/typescript": { 449 | "version": "5.4.2", 450 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.2.tgz", 451 | "integrity": "sha512-+2/g0Fds1ERlP6JsakQQDXjZdZMM+rqpamFZJEKh4kwTIn3iDkgKtby0CeNd5ATNZ4Ry1ax15TMx0W2V+miizQ==", 452 | "dev": true, 453 | "bin": { 454 | "tsc": "bin/tsc", 455 | "tsserver": "bin/tsserver" 456 | }, 457 | "engines": { 458 | "node": ">=14.17" 459 | } 460 | } 461 | } 462 | } 463 | -------------------------------------------------------------------------------- /examples/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chessops-examples", 3 | "private": true, 4 | "license": "GPL-3.0-or-later", 5 | "type": "module", 6 | "dependencies": { 7 | "chessops": "file:.." 8 | }, 9 | "devDependencies": { 10 | "esbuild": "^0.20", 11 | "typescript": "^5" 12 | }, 13 | "scripts": { 14 | "prepare": "esbuild src/read-pgn.ts --minify --bundle --sourcemap --outdir=dist --platform=node --format=esm", 15 | "read-pgn": "time node --enable-source-maps --experimental-vm-modules dist/read-pgn.js" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /examples/src/read-pgn.ts: -------------------------------------------------------------------------------- 1 | import { makeFen } from 'chessops/fen'; 2 | import { PgnParser, startingPosition, walk } from 'chessops/pgn'; 3 | import { parseSan } from 'chessops/san'; 4 | import { createReadStream } from 'fs'; 5 | 6 | const status = { 7 | games: 0, 8 | errors: 0, 9 | moves: 0, 10 | }; 11 | 12 | for (const arg of process.argv.slice(2)) { 13 | console.log('#', arg); 14 | 15 | const stream = createReadStream(arg, { encoding: 'utf-8' }); 16 | 17 | const parser = new PgnParser((game, err) => { 18 | status.games++; 19 | 20 | if (err) { 21 | console.error(err); 22 | status.errors++; 23 | stream.destroy(err); 24 | } 25 | 26 | startingPosition(game.headers).unwrap( 27 | pos => 28 | walk(game.moves, pos, (pos, node) => { 29 | const move = parseSan(pos, node.san); 30 | if (!move) { 31 | console.error(node, game.headers, makeFen(pos.toSetup())); 32 | status.errors++; 33 | return false; 34 | } else { 35 | pos.play(move); 36 | status.moves++; 37 | } 38 | return true; 39 | }), 40 | err => { 41 | console.error(err, game.headers); 42 | status.errors++; 43 | }, 44 | ); 45 | 46 | if (status.games % 1024 === 0) console.log(status); 47 | }); 48 | 49 | await new Promise(resolve => 50 | stream 51 | .on('data', (chunk: string) => parser.parse(chunk, { stream: true })) 52 | .on('close', () => { 53 | parser.parse(''); 54 | console.log(status); 55 | resolve(); 56 | }) 57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /examples/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "declaration": false, 6 | "declarationDir": null, 7 | "sourceMap": false 8 | }, 9 | "include": ["src"] 10 | } 11 | -------------------------------------------------------------------------------- /fuzz/.gitignore: -------------------------------------------------------------------------------- 1 | .nyc_output/ 2 | corpus/ 3 | -------------------------------------------------------------------------------- /fuzz/README.md: -------------------------------------------------------------------------------- 1 | # chessops-fuzz 2 | 3 | ## Installing 4 | 5 | ```sh 6 | npm config set @gitlab-org:registry https://gitlab.com/api/v4/packages/npm/ 7 | npm install 8 | ``` 9 | 10 | ## Running 11 | 12 | ```sh 13 | npm run fuzz-fen 14 | ``` 15 | -------------------------------------------------------------------------------- /fuzz/corpus/fen/empty: -------------------------------------------------------------------------------- 1 | 8/8/8/8/8/8/8/8 w - - 0 1 2 | -------------------------------------------------------------------------------- /fuzz/corpus/fen/endgame: -------------------------------------------------------------------------------- 1 | 1Q6/p3R3/6rp/P1K2k2/8/8/6pP/8 w - - 0 46 2 | -------------------------------------------------------------------------------- /fuzz/corpus/fen/horde: -------------------------------------------------------------------------------- 1 | rnbqkb1r/p1p1nppp/2Pp4/3P1PP1/PPPPPP1P/PPP1PPPP/PPPPPPPP/PPPPPPPP w kq - 1 6 2 | -------------------------------------------------------------------------------- /fuzz/corpus/fen/initial: -------------------------------------------------------------------------------- 1 | rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1 2 | -------------------------------------------------------------------------------- /fuzz/corpus/fen/scid-3check: -------------------------------------------------------------------------------- 1 | r1b2rk1/ppp2p1p/4p1p1/4Bn2/1b2N3/3B4/PPP3PP/R2Q1R1K b - - 0 15 +0+0 2 | -------------------------------------------------------------------------------- /fuzz/corpus/fen/scid-zh: -------------------------------------------------------------------------------- 1 | 1r3rkQ/pPp2p1p/1pp2Bp1/8/3P4/4P3/PP3PPP/R2Q1RK1/BPNBnbnn b - - 39 20 2 | -------------------------------------------------------------------------------- /fuzz/corpus/fen/too-many-empty-ranks: -------------------------------------------------------------------------------- 1 | 8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/8/88/8/8/8/8/8/8/8/8/[] -------------------------------------------------------------------------------- /fuzz/corpus/fen/winboard-3check: -------------------------------------------------------------------------------- 1 | r1b2rk1/ppp2p1p/4p1p1/4Bn2/1b2N3/3B4/PPP3PP/R2Q1R1K b - - 3+3 0 15 2 | -------------------------------------------------------------------------------- /fuzz/corpus/fen/winboard-zh: -------------------------------------------------------------------------------- 1 | r4rk1/p2bppbp/6p1/2qpP3/5P2/2NBPQPp/PPP4P/R1B3K1[NNNrp] b - - 39 20 2 | -------------------------------------------------------------------------------- /fuzz/corpus/pgn-comment/comment: -------------------------------------------------------------------------------- 1 | [%csl Ga1][%cal Ra1h1,Gb1b8] foo [%clk 3:25:45] [%eval #1,5] 2 | -------------------------------------------------------------------------------- /fuzz/corpus/pgn/bare: -------------------------------------------------------------------------------- 1 | 1. e4 e5 2 | 2. Nf3 3 | -------------------------------------------------------------------------------- /fuzz/corpus/pgn/small: -------------------------------------------------------------------------------- 1 | [White "white"] 2 | 3 | 1. Nf3 ! (1. e4 {comment} $1 ) * 4 | 5 | -------------------------------------------------------------------------------- /fuzz/corpus/san/capture: -------------------------------------------------------------------------------- 1 | Q1xd4 2 | -------------------------------------------------------------------------------- /fuzz/corpus/san/castle-long: -------------------------------------------------------------------------------- 1 | O-O-O 2 | -------------------------------------------------------------------------------- /fuzz/corpus/san/castle-short: -------------------------------------------------------------------------------- 1 | O-O 2 | -------------------------------------------------------------------------------- /fuzz/corpus/san/drop: -------------------------------------------------------------------------------- 1 | Q@f8 2 | -------------------------------------------------------------------------------- /fuzz/corpus/san/longest: -------------------------------------------------------------------------------- 1 | Qa1h8=K# 2 | -------------------------------------------------------------------------------- /fuzz/corpus/san/move: -------------------------------------------------------------------------------- 1 | Nf3 2 | -------------------------------------------------------------------------------- /fuzz/corpus/san/promotion: -------------------------------------------------------------------------------- 1 | e8=Q 2 | -------------------------------------------------------------------------------- /fuzz/corpus/uci/drop: -------------------------------------------------------------------------------- 1 | P@d2 2 | -------------------------------------------------------------------------------- /fuzz/corpus/uci/move: -------------------------------------------------------------------------------- 1 | a1h8 2 | -------------------------------------------------------------------------------- /fuzz/corpus/uci/null: -------------------------------------------------------------------------------- 1 | 0000 2 | -------------------------------------------------------------------------------- /fuzz/corpus/uci/promotion: -------------------------------------------------------------------------------- 1 | e7e8q 2 | -------------------------------------------------------------------------------- /fuzz/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chessops-fuzz", 3 | "private": true, 4 | "license": "GPL-3.0-or-later", 5 | "type": "commonjs", 6 | "dependencies": { 7 | "chessops": "file:.." 8 | }, 9 | "devDependencies": { 10 | "@gitlab-org/jsfuzz": "=1.2.2", 11 | "esbuild": "^0.20", 12 | "typescript": "^5" 13 | }, 14 | "scripts": { 15 | "compile": "find src -name '*.ts' -exec esbuild {} --bundle --outdir=dist --platform=node \\;", 16 | "fuzz-uci": "$npm_execpath run compile && node node_modules/@gitlab-org/jsfuzz dist/uci.fuzz.js corpus/uci", 17 | "fuzz-san": "$npm_execpath run compile && node node_modules/@gitlab-org/jsfuzz dist/san.fuzz.js corpus/san", 18 | "fuzz-fen": "$npm_execpath run compile && node node_modules/@gitlab-org/jsfuzz dist/fen.fuzz.js corpus/fen", 19 | "fuzz-pgn": "$npm_execpath run compile && node node_modules/@gitlab-org/jsfuzz dist/pgn.fuzz.js corpus/pgn", 20 | "fuzz-pgn-comment": "$npm_execpath run compile && node node_modules/@gitlab-org/jsfuzz dist/pgn-comment.fuzz.js corpus/pgn-comment" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /fuzz/src/fen.fuzz.ts: -------------------------------------------------------------------------------- 1 | import { makeFen, parseFen } from 'chessops/fen'; 2 | import { setupEquals } from 'chessops/setup'; 3 | 4 | export const fuzz = (data: Buffer): void => { 5 | parseFen(data.toString()).map(setup => { 6 | const roundtripped = parseFen(makeFen(setup)).unwrap(); 7 | if (!setupEquals(setup, roundtripped)) throw 'setup not equal'; 8 | }); 9 | }; 10 | -------------------------------------------------------------------------------- /fuzz/src/pgn-comment.fuzz.ts: -------------------------------------------------------------------------------- 1 | import { Comment, CommentShape, isMate, isPawns, makeComment, parseComment } from 'chessops/pgn'; 2 | 3 | const mate = (comment: Comment): number | undefined => 4 | comment.evaluation && isMate(comment.evaluation) ? comment.evaluation.mate : undefined; 5 | 6 | const pawns = (comment: Comment): number | undefined => 7 | comment.evaluation && isPawns(comment.evaluation) ? comment.evaluation.pawns : undefined; 8 | 9 | const compareShape = (left: CommentShape, right: CommentShape): number => { 10 | if (left.from !== right.from) return left.from - right.from; 11 | if (left.to !== right.to) return left.to - right.to; 12 | if (left.color < right.color) return -1; 13 | if (left.color > right.color) return 1; 14 | return 0; 15 | }; 16 | 17 | export const fuzz = (data: Buffer): void => { 18 | const comment = parseComment(data.toString()); 19 | const rountripped = parseComment(makeComment(comment)); 20 | if (comment.text !== rountripped.text) throw 'text not equal'; 21 | if (mate(comment) !== mate(rountripped)) throw 'mate not equal'; 22 | if (pawns(comment) !== pawns(rountripped)) throw 'pawns not equal'; 23 | if (comment.emt !== rountripped.emt) throw 'emt not equal'; 24 | if (comment.clock !== rountripped.clock) throw 'clock not equal'; 25 | if (comment.shapes.length !== rountripped.shapes.length) throw 'shapes not equal'; 26 | comment.shapes.sort(compareShape); 27 | rountripped.shapes.sort(compareShape); 28 | for (let i = 0; i < comment.shapes.length; i++) { 29 | if (compareShape(comment.shapes[i], rountripped.shapes[i])) throw 'shape not equal'; 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /fuzz/src/pgn.fuzz.ts: -------------------------------------------------------------------------------- 1 | import { emptyHeaders, PgnParser } from 'chessops/pgn'; 2 | 3 | export const fuzz = (data: Buffer): void => { 4 | new PgnParser(() => {}, emptyHeaders).parse(data.toString()); 5 | }; 6 | -------------------------------------------------------------------------------- /fuzz/src/san.fuzz.ts: -------------------------------------------------------------------------------- 1 | import { Chess } from 'chessops/chess'; 2 | import { parseFen } from 'chessops/fen'; 3 | import { makeSan, parseSan } from 'chessops/san'; 4 | import { moveEquals } from 'chessops/util'; 5 | 6 | const setup = parseFen('2rqk2r/1b2bppp/1p2p3/n1ppPn2/2PP4/PP3N2/1BpNQPPP/RB3RK1 b k - 0 1').unwrap(); 7 | const pos = Chess.fromSetup(setup).unwrap(); 8 | 9 | export const fuzz = (data: Buffer): void => { 10 | const move = parseSan(pos, data.toString()); 11 | if (move) { 12 | const roundtripped = parseSan(pos, makeSan(pos, move)); 13 | if (!roundtripped) throw 'roundtrip failed'; 14 | if (!moveEquals(move, roundtripped)) throw 'move not equal'; 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /fuzz/src/uci.fuzz.ts: -------------------------------------------------------------------------------- 1 | import { makeUci, moveEquals, parseUci } from 'chessops/util'; 2 | 3 | export const fuzz = (data: Buffer): void => { 4 | const move = parseUci(data.toString()); 5 | if (move) { 6 | const roundtripped = parseUci(makeUci(move)); 7 | if (!roundtripped) throw 'roundtrip failed'; 8 | if (!moveEquals(move, roundtripped)) throw 'move not equal'; 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /fuzz/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "declaration": false, 6 | "declarationDir": null, 7 | "sourceMap": false 8 | }, 9 | "include": ["src"] 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chessops", 3 | "version": "0.14.2", 4 | "description": "Chess and chess variant rules and operations", 5 | "keywords": [ 6 | "chess", 7 | "lichess", 8 | "fen", 9 | "pgn", 10 | "uci", 11 | "typescript" 12 | ], 13 | "repository": "github:niklasf/chessops", 14 | "author": "Niklas Fiekas ", 15 | "funding": "https://github.com/sponsors/niklasf", 16 | "license": "GPL-3.0-or-later", 17 | "type": "module", 18 | "module": "dist/esm/index.js", 19 | "main": "dist/cjs/index.js", 20 | "types": "index.d.ts", 21 | "typesVersions": { 22 | "*": { 23 | "*": [ 24 | "dist/types/*" 25 | ] 26 | } 27 | }, 28 | "exports": { 29 | ".": { 30 | "import": "./dist/esm/index.js", 31 | "require": "./dist/cjs/index.js", 32 | "types": "./dist/types/index.d.ts" 33 | }, 34 | "./*": { 35 | "import": "./dist/esm/*.js", 36 | "require": "./dist/cjs/*.js", 37 | "types": "./dist/types/*.d.ts" 38 | } 39 | }, 40 | "sideEffects": false, 41 | "dependencies": { 42 | "@badrap/result": "^0.2" 43 | }, 44 | "devDependencies": { 45 | "@jest/globals": "^29", 46 | "@typescript-eslint/eslint-plugin": "^8", 47 | "@typescript-eslint/parser": "^8", 48 | "dprint": "^0.49", 49 | "eslint": "^9", 50 | "jest": "^29", 51 | "ts-jest": "^29", 52 | "typedoc": "^0.28", 53 | "typescript": "^5" 54 | }, 55 | "scripts": { 56 | "prepare": "tsc --declarationDir dist/types && tsc --outDir dist/cjs --module commonjs --declaration false", 57 | "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js", 58 | "doc": "typedoc src/types.ts src/attacks.ts src/util.ts src/squareSet.ts src/board.ts src/setup.ts src/chess.ts src/compat.ts src/debug.ts src/fen.ts src/san.ts src/transform.ts src/variant.ts src/pgn.ts", 59 | "lint": "eslint", 60 | "format": "dprint fmt", 61 | "check-format": "dprint check" 62 | }, 63 | "files": [ 64 | "/src", 65 | "/dist", 66 | "!/**/*.test.*" 67 | ], 68 | "jest": { 69 | "testRegex": ".*\\.test\\.ts$", 70 | "transform": { 71 | "\\.ts$": [ 72 | "ts-jest", 73 | { 74 | "useESM": true 75 | } 76 | ] 77 | }, 78 | "extensionsToTreatAsEsm": [ 79 | ".ts" 80 | ], 81 | "moduleNameMapper": { 82 | "^(.*)\\.js$": "$1" 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/attacks.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@jest/globals'; 2 | import { between, ray, rookAttacks } from './attacks.js'; 3 | import { SquareSet } from './squareSet.js'; 4 | 5 | test('rook attacks', () => { 6 | const d6 = 43; 7 | expect(rookAttacks(d6, new SquareSet(0x2826f5b9, 0x3f7f2880))).toEqual(new SquareSet(0x8000000, 0x83708)); 8 | expect(rookAttacks(d6, SquareSet.empty())).toEqual(SquareSet.fromFile(3).xor(SquareSet.fromRank(5))); 9 | }); 10 | 11 | test('ray', () => { 12 | expect(ray(0, 8)).toEqual(SquareSet.fromFile(0)); 13 | }); 14 | 15 | test('between', () => { 16 | expect(between(42, 42)).toEqual(SquareSet.empty()); 17 | expect(Array.from(between(0, 3))).toEqual([1, 2]); 18 | 19 | expect(Array.from(between(61, 47))).toEqual([54]); 20 | expect(Array.from(between(47, 61))).toEqual([54]); 21 | }); 22 | -------------------------------------------------------------------------------- /src/attacks.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Compute attacks and rays. 3 | * 4 | * These are low-level functions that can be used to implement chess rules. 5 | * 6 | * Implementation notes: Sliding attacks are computed using 7 | * [Hyperbola Quintessence](https://www.chessprogramming.org/Hyperbola_Quintessence). 8 | * Magic Bitboards would deliver slightly faster lookups, but also require 9 | * initializing considerably larger attack tables. On the web, initialization 10 | * time is important, so the chosen method may strike a better balance. 11 | * 12 | * @packageDocumentation 13 | */ 14 | 15 | import { SquareSet } from './squareSet.js'; 16 | import { BySquare, Color, Piece, Square } from './types.js'; 17 | import { squareFile, squareRank } from './util.js'; 18 | 19 | const computeRange = (square: Square, deltas: number[]): SquareSet => { 20 | let range = SquareSet.empty(); 21 | for (const delta of deltas) { 22 | const sq = square + delta; 23 | if (0 <= sq && sq < 64 && Math.abs(squareFile(square) - squareFile(sq)) <= 2) { 24 | range = range.with(sq); 25 | } 26 | } 27 | return range; 28 | }; 29 | 30 | const tabulate = (f: (square: Square) => T): BySquare => { 31 | const table = []; 32 | for (let square = 0; square < 64; square++) table[square] = f(square); 33 | return table; 34 | }; 35 | 36 | const KING_ATTACKS = tabulate(sq => computeRange(sq, [-9, -8, -7, -1, 1, 7, 8, 9])); 37 | const KNIGHT_ATTACKS = tabulate(sq => computeRange(sq, [-17, -15, -10, -6, 6, 10, 15, 17])); 38 | const PAWN_ATTACKS = { 39 | white: tabulate(sq => computeRange(sq, [7, 9])), 40 | black: tabulate(sq => computeRange(sq, [-7, -9])), 41 | }; 42 | 43 | /** 44 | * Gets squares attacked or defended by a king on `square`. 45 | */ 46 | export const kingAttacks = (square: Square): SquareSet => KING_ATTACKS[square]; 47 | 48 | /** 49 | * Gets squares attacked or defended by a knight on `square`. 50 | */ 51 | export const knightAttacks = (square: Square): SquareSet => KNIGHT_ATTACKS[square]; 52 | 53 | /** 54 | * Gets squares attacked or defended by a pawn of the given `color` 55 | * on `square`. 56 | */ 57 | export const pawnAttacks = (color: Color, square: Square): SquareSet => PAWN_ATTACKS[color][square]; 58 | 59 | const FILE_RANGE = tabulate(sq => SquareSet.fromFile(squareFile(sq)).without(sq)); 60 | const RANK_RANGE = tabulate(sq => SquareSet.fromRank(squareRank(sq)).without(sq)); 61 | 62 | const DIAG_RANGE = tabulate(sq => { 63 | const diag = new SquareSet(0x0804_0201, 0x8040_2010); 64 | const shift = 8 * (squareRank(sq) - squareFile(sq)); 65 | return (shift >= 0 ? diag.shl64(shift) : diag.shr64(-shift)).without(sq); 66 | }); 67 | 68 | const ANTI_DIAG_RANGE = tabulate(sq => { 69 | const diag = new SquareSet(0x1020_4080, 0x0102_0408); 70 | const shift = 8 * (squareRank(sq) + squareFile(sq) - 7); 71 | return (shift >= 0 ? diag.shl64(shift) : diag.shr64(-shift)).without(sq); 72 | }); 73 | 74 | const hyperbola = (bit: SquareSet, range: SquareSet, occupied: SquareSet): SquareSet => { 75 | let forward = occupied.intersect(range); 76 | let reverse = forward.bswap64(); // Assumes no more than 1 bit per rank 77 | forward = forward.minus64(bit); 78 | reverse = reverse.minus64(bit.bswap64()); 79 | return forward.xor(reverse.bswap64()).intersect(range); 80 | }; 81 | 82 | const fileAttacks = (square: Square, occupied: SquareSet): SquareSet => 83 | hyperbola(SquareSet.fromSquare(square), FILE_RANGE[square], occupied); 84 | 85 | const rankAttacks = (square: Square, occupied: SquareSet): SquareSet => { 86 | const range = RANK_RANGE[square]; 87 | let forward = occupied.intersect(range); 88 | let reverse = forward.rbit64(); 89 | forward = forward.minus64(SquareSet.fromSquare(square)); 90 | reverse = reverse.minus64(SquareSet.fromSquare(63 - square)); 91 | return forward.xor(reverse.rbit64()).intersect(range); 92 | }; 93 | 94 | /** 95 | * Gets squares attacked or defended by a bishop on `square`, given `occupied` 96 | * squares. 97 | */ 98 | export const bishopAttacks = (square: Square, occupied: SquareSet): SquareSet => { 99 | const bit = SquareSet.fromSquare(square); 100 | return hyperbola(bit, DIAG_RANGE[square], occupied).xor(hyperbola(bit, ANTI_DIAG_RANGE[square], occupied)); 101 | }; 102 | 103 | /** 104 | * Gets squares attacked or defended by a rook on `square`, given `occupied` 105 | * squares. 106 | */ 107 | export const rookAttacks = (square: Square, occupied: SquareSet): SquareSet => 108 | fileAttacks(square, occupied).xor(rankAttacks(square, occupied)); 109 | 110 | /** 111 | * Gets squares attacked or defended by a queen on `square`, given `occupied` 112 | * squares. 113 | */ 114 | export const queenAttacks = (square: Square, occupied: SquareSet): SquareSet => 115 | bishopAttacks(square, occupied).xor(rookAttacks(square, occupied)); 116 | 117 | /** 118 | * Gets squares attacked or defended by a `piece` on `square`, given 119 | * `occupied` squares. 120 | */ 121 | export const attacks = (piece: Piece, square: Square, occupied: SquareSet): SquareSet => { 122 | switch (piece.role) { 123 | case 'pawn': 124 | return pawnAttacks(piece.color, square); 125 | case 'knight': 126 | return knightAttacks(square); 127 | case 'bishop': 128 | return bishopAttacks(square, occupied); 129 | case 'rook': 130 | return rookAttacks(square, occupied); 131 | case 'queen': 132 | return queenAttacks(square, occupied); 133 | case 'king': 134 | return kingAttacks(square); 135 | } 136 | }; 137 | 138 | /** 139 | * Gets all squares of the rank, file or diagonal with the two squares 140 | * `a` and `b`, or an empty set if they are not aligned. 141 | */ 142 | export const ray = (a: Square, b: Square): SquareSet => { 143 | const other = SquareSet.fromSquare(b); 144 | if (RANK_RANGE[a].intersects(other)) return RANK_RANGE[a].with(a); 145 | if (ANTI_DIAG_RANGE[a].intersects(other)) return ANTI_DIAG_RANGE[a].with(a); 146 | if (DIAG_RANGE[a].intersects(other)) return DIAG_RANGE[a].with(a); 147 | if (FILE_RANGE[a].intersects(other)) return FILE_RANGE[a].with(a); 148 | return SquareSet.empty(); 149 | }; 150 | 151 | /** 152 | * Gets all squares between `a` and `b` (bounds not included), or an empty set 153 | * if they are not on the same rank, file or diagonal. 154 | */ 155 | export const between = (a: Square, b: Square): SquareSet => 156 | ray(a, b) 157 | .intersect(SquareSet.full().shl64(a).xor(SquareSet.full().shl64(b))) 158 | .withoutFirst(); 159 | -------------------------------------------------------------------------------- /src/board.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@jest/globals'; 2 | import { Board, boardEquals } from './board.js'; 3 | import { Piece } from './types.js'; 4 | 5 | test('set and get', () => { 6 | const emptyBoard = Board.empty(); 7 | expect(emptyBoard.getColor(0)).toBeUndefined(); 8 | expect(emptyBoard.getRole(0)).toBeUndefined(); 9 | expect(emptyBoard.has(0)).toBe(false); 10 | expect(emptyBoard.get(0)).toBeUndefined(); 11 | expect(boardEquals(emptyBoard, emptyBoard.clone())).toBe(true); 12 | 13 | const board = emptyBoard.clone(); 14 | const piece: Piece = { role: 'knight', color: 'black', promoted: false }; 15 | expect(board.set(0, piece)).toBeUndefined(); 16 | expect(board.getColor(0)).toBe('black'); 17 | expect(board.getRole(0)).toBe('knight'); 18 | expect(board.has(0)).toBe(true); 19 | expect(board.get(0)).toEqual(piece); 20 | expect(boardEquals(board, board.clone())).toBe(true); 21 | expect(boardEquals(emptyBoard, board)).toBe(false); 22 | }); 23 | -------------------------------------------------------------------------------- /src/board.ts: -------------------------------------------------------------------------------- 1 | import { SquareSet } from './squareSet.js'; 2 | import { ByColor, ByRole, Color, COLORS, Piece, Role, ROLES, Square } from './types.js'; 3 | 4 | /** 5 | * Piece positions on a board. 6 | * 7 | * Properties are sets of squares, like `board.occupied` for all occupied 8 | * squares, `board[color]` for all pieces of that color, and `board[role]` 9 | * for all pieces of that role. When modifying the properties directly, take 10 | * care to keep them consistent. 11 | */ 12 | export class Board implements Iterable<[Square, Piece]>, ByRole, ByColor { 13 | /** 14 | * All occupied squares. 15 | */ 16 | occupied: SquareSet; 17 | /** 18 | * All squares occupied by pieces known to be promoted. This information is 19 | * relevant in chess variants like Crazyhouse. 20 | */ 21 | promoted: SquareSet; 22 | 23 | white: SquareSet; 24 | black: SquareSet; 25 | 26 | pawn: SquareSet; 27 | knight: SquareSet; 28 | bishop: SquareSet; 29 | rook: SquareSet; 30 | queen: SquareSet; 31 | king: SquareSet; 32 | 33 | private constructor() {} 34 | 35 | static default(): Board { 36 | const board = new Board(); 37 | board.reset(); 38 | return board; 39 | } 40 | 41 | /** 42 | * Resets all pieces to the default starting position for standard chess. 43 | */ 44 | reset(): void { 45 | this.occupied = new SquareSet(0xffff, 0xffff_0000); 46 | this.promoted = SquareSet.empty(); 47 | this.white = new SquareSet(0xffff, 0); 48 | this.black = new SquareSet(0, 0xffff_0000); 49 | this.pawn = new SquareSet(0xff00, 0x00ff_0000); 50 | this.knight = new SquareSet(0x42, 0x4200_0000); 51 | this.bishop = new SquareSet(0x24, 0x2400_0000); 52 | this.rook = new SquareSet(0x81, 0x8100_0000); 53 | this.queen = new SquareSet(0x8, 0x0800_0000); 54 | this.king = new SquareSet(0x10, 0x1000_0000); 55 | } 56 | 57 | static empty(): Board { 58 | const board = new Board(); 59 | board.clear(); 60 | return board; 61 | } 62 | 63 | clear(): void { 64 | this.occupied = SquareSet.empty(); 65 | this.promoted = SquareSet.empty(); 66 | for (const color of COLORS) this[color] = SquareSet.empty(); 67 | for (const role of ROLES) this[role] = SquareSet.empty(); 68 | } 69 | 70 | clone(): Board { 71 | const board = new Board(); 72 | board.occupied = this.occupied; 73 | board.promoted = this.promoted; 74 | for (const color of COLORS) board[color] = this[color]; 75 | for (const role of ROLES) board[role] = this[role]; 76 | return board; 77 | } 78 | 79 | getColor(square: Square): Color | undefined { 80 | if (this.white.has(square)) return 'white'; 81 | if (this.black.has(square)) return 'black'; 82 | return; 83 | } 84 | 85 | getRole(square: Square): Role | undefined { 86 | for (const role of ROLES) { 87 | if (this[role].has(square)) return role; 88 | } 89 | return; 90 | } 91 | 92 | get(square: Square): Piece | undefined { 93 | const color = this.getColor(square); 94 | if (!color) return; 95 | const role = this.getRole(square)!; 96 | const promoted = this.promoted.has(square); 97 | return { color, role, promoted }; 98 | } 99 | 100 | /** 101 | * Removes and returns the piece from the given `square`, if any. 102 | */ 103 | take(square: Square): Piece | undefined { 104 | const piece = this.get(square); 105 | if (piece) { 106 | this.occupied = this.occupied.without(square); 107 | this[piece.color] = this[piece.color].without(square); 108 | this[piece.role] = this[piece.role].without(square); 109 | if (piece.promoted) this.promoted = this.promoted.without(square); 110 | } 111 | return piece; 112 | } 113 | 114 | /** 115 | * Put `piece` onto `square`, potentially replacing an existing piece. 116 | * Returns the existing piece, if any. 117 | */ 118 | set(square: Square, piece: Piece): Piece | undefined { 119 | const old = this.take(square); 120 | this.occupied = this.occupied.with(square); 121 | this[piece.color] = this[piece.color].with(square); 122 | this[piece.role] = this[piece.role].with(square); 123 | if (piece.promoted) this.promoted = this.promoted.with(square); 124 | return old; 125 | } 126 | 127 | has(square: Square): boolean { 128 | return this.occupied.has(square); 129 | } 130 | 131 | *[Symbol.iterator](): Iterator<[Square, Piece]> { 132 | for (const square of this.occupied) { 133 | yield [square, this.get(square)!]; 134 | } 135 | } 136 | 137 | pieces(color: Color, role: Role): SquareSet { 138 | return this[color].intersect(this[role]); 139 | } 140 | 141 | rooksAndQueens(): SquareSet { 142 | return this.rook.union(this.queen); 143 | } 144 | 145 | bishopsAndQueens(): SquareSet { 146 | return this.bishop.union(this.queen); 147 | } 148 | 149 | /** 150 | * Finds the unique king of the given `color`, if any. 151 | */ 152 | kingOf(color: Color): Square | undefined { 153 | return this.pieces(color, 'king').singleSquare(); 154 | } 155 | } 156 | 157 | export const boardEquals = (left: Board, right: Board): boolean => 158 | left.white.equals(right.white) 159 | && left.promoted.equals(right.promoted) 160 | && ROLES.every(role => left[role].equals(right[role])); 161 | -------------------------------------------------------------------------------- /src/chess.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@jest/globals'; 2 | import { Castles, castlingSide, Chess, isImpossibleCheck, normalizeMove } from './chess.js'; 3 | import { perft } from './debug.js'; 4 | import { INITIAL_FEN, makeFen, parseFen } from './fen.js'; 5 | import { SquareSet } from './squareSet.js'; 6 | import { parseUci } from './util.js'; 7 | 8 | const tricky: [string, string, number, number, number, number?, number?][] = [ 9 | ['pos-2', 'r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq -', 48, 2039, 97862], // Kiwipete by Peter McKenzie 10 | ['pos-3', '8/2p5/3p4/KP5r/1R3p1k/8/4P1P1/8 w - -', 14, 191, 2812, 43238], 11 | ['pos-4', 'r2q1rk1/pP1p2pp/Q4n2/bbp1p3/Np6/1B3NBn/pPPP1PPP/R3K2R b KQ -', 6, 264, 9467], 12 | ['pos-5', 'rnbq1k1r/pp1Pbppp/2p5/8/2B5/8/PPP1NnPP/RNBQK2R w KQ -', 44, 1486, 62379], // http://www.talkchess.com/forum/viewtopic.php?t=42463 13 | ['pos-6', 'r4rk1/1pp1qppp/p1np1n2/2b1p1B1/2B1P1b1/P1NP1N2/1PP1QPPP/R4RK1 w - -', 46, 2079, 89890], // By Steven Edwards 14 | 15 | // http://www.talkchess.com/forum/viewtopic.php?t=55274 16 | ['xfen-00', 'r1k1r2q/p1ppp1pp/8/8/8/8/P1PPP1PP/R1K1R2Q w KQkq -', 23, 522, 12333, 285754], 17 | ['xfen-01', 'r1k2r1q/p1ppp1pp/8/8/8/8/P1PPP1PP/R1K2R1Q w KQkq -', 28, 738, 20218, 541480], 18 | ['xfen-02', '8/8/8/4B2b/6nN/8/5P2/2R1K2k w Q -', 34, 318, 9002, 118388], 19 | ['xfen-03', '2r5/8/8/8/8/8/6PP/k2KR3 w K -', 17, 242, 3931, 57700], 20 | ['xfen-04', '4r3/3k4/8/8/8/8/6PP/qR1K1R2 w KQ -', 19, 628, 12858, 405636], 21 | 22 | // Regression tests 23 | ['ep-evasion', '8/8/8/5k2/3p4/8/4P3/4K3 w - -', 6, 54, 343, 2810, 19228], 24 | ['prison', '2b5/kpPp4/1p1P4/1P6/6p1/4p1P1/4PpPK/5B2 w - -', 1, 1, 1], 25 | ['king-walk', '8/8/8/B2p3Q/2qPp1P1/b7/2P2PkP/4K2R b K -', 26, 611, 14583, 366807], 26 | ['a1-check', '4k3/5p2/5p1p/8/rbR5/1N6/5PPP/5K2 b - - 1 29', 22, 580, 12309], 27 | 28 | // https://github.com/ornicar/lila/issues/4625 29 | [ 30 | 'hside-rook-blocks-aside-castling', 31 | '4rrk1/pbbp2p1/1ppnp3/3n1pqp/3N1PQP/1PPNP3/PBBP2P1/4RRK1 w Ff -', 32 | 42, 33 | 1743, 34 | 71908, 35 | ], 36 | 37 | // Impossible checker alignment 38 | ['align-diag-1', '3R4/8/q4k2/2B5/1NK5/3b4/8/8 w - -', 4, 125, 2854], 39 | ['align-diag-2', '2Nq4/2K5/1b6/8/7R/3k4/7P/8 w - -', 3, 81, 1217], 40 | ['align-horizontal', '5R2/2P5/8/4k3/8/3rK2r/8/8 w - -', 2, 56, 1030], 41 | ['align-ep', '8/8/8/1k6/3Pp3/8/8/4KQ2 b - d3', 6, 121, 711], 42 | ['align-ep-pinned', '1b1k4/8/8/1rPpK3/8/8/8/8 w - d6', 5, 100, 555], 43 | ['ep-unrelated-check', 'rnbqk1nr/bb3p1p/1q2r3/2pPp3/3P4/7P/1PP1NpPP/R1BQKBNR w KQkq c6', 2, 92, 2528], 44 | 45 | // Impossible castling rights 46 | ['asymmetrical-and-king-on-h', 'r2r3k/p7/3p4/8/8/P6P/8/R3K2R b KQq -', 14, 206, 3672, 64639, 1320962], 47 | ]; 48 | 49 | const random: [string, string, number, number, number, number, number][] = [ 50 | ['gentest-1', 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq -', 20, 400, 8902, 197281, 4865609], 51 | ['gentest-2', 'rnbqkbnr/pp1ppppp/2p5/8/6P1/2P5/PP1PPP1P/RNBQKBNR b KQkq -', 21, 463, 11138, 274234, 7290026], 52 | ['gentest-3', 'rnb1kbnr/ppq1pppp/2pp4/8/6P1/2P5/PP1PPPBP/RNBQK1NR w KQkq -', 27, 734, 20553, 579004, 16988496], 53 | ['gentest-4', 'rnb1kbnr/p1q1pppp/1ppp4/8/4B1P1/2P5/PPQPPP1P/RNB1K1NR b KQkq -', 28, 837, 22536, 699777, 19118920], 54 | ['gentest-5', 'rn2kbnr/p1q1ppp1/1ppp3p/8/4B1b1/2P4P/PPQPPP2/RNB1K1NR w KQkq -', 29, 827, 24815, 701084, 21819626], 55 | ['gentest-6', 'rn1qkbnr/p3ppp1/1ppp2Qp/3B4/6b1/2P4P/PP1PPP2/RNB1K1NR b KQkq -', 25, 976, 23465, 872551, 21984216], 56 | ['gentest-7', 'rnkq1bnr/p3ppp1/1ppp3p/3B4/6b1/2PQ3P/PP1PPP2/RNB1K1NR w KQ -', 36, 957, 33542, 891412, 31155934], 57 | ['gentest-8', 'rnkq1bnr/p3ppp1/1ppp3p/5b2/8/2PQ3P/PP1PPPB1/RNB1K1NR b KQ -', 29, 927, 25822, 832461, 23480361], 58 | ['gentest-9', 'rn1q1bnr/p2kppp1/2pp3p/1p3b2/1P6/2PQ3P/P2PPPB1/RNB1K1NR w KQ -', 31, 834, 25926, 715605, 22575950], 59 | ['gentest-10', 'rn1q1bnr/3kppp1/p1pp3p/1p3b2/1P6/2P2N1P/P1QPPPB1/RNB1K2R b KQ -', 29, 900, 25008, 781431, 22075119], 60 | ['gentest-94', '2b1kbnB/rppqp3/3p3p/3P1pp1/pnP3P1/PP2P2P/4QP2/RN2KBNR b KQ -', 27, 729, 20665, 613681, 18161673], 61 | ['gentest-95', '2b1kbnB/r1pqp3/n2p3p/1p1P1pp1/p1P3P1/PP2P2P/Q4P2/RN2KBNR w KQ -', 30, 689, 21830, 556204, 18152100], 62 | ['gentest-96', '2b1kbn1/r1pqp3/n2p3p/3P1pp1/ppP3P1/PPB1P2P/Q4P2/RN2KBNR b KQ -', 23, 685, 17480, 532817, 14672791], 63 | ['chess960-1', 'bqnb1rkr/pp3ppp/3ppn2/2p5/5P2/P2P4/NPP1P1PP/BQ1BNRKR w HFhf - 2 9', 21, 528, 12189, 326672, 8146062], 64 | ]; 65 | 66 | test('castles from setup', () => { 67 | const setup = parseFen(INITIAL_FEN).unwrap(); 68 | const castles = Castles.fromSetup(setup); 69 | 70 | expect(castles.castlingRights).toEqual(SquareSet.corners()); 71 | 72 | expect(castles.rook.white.a).toBe(0); 73 | expect(castles.rook.white.h).toBe(7); 74 | expect(castles.rook.black.a).toBe(56); 75 | expect(castles.rook.black.h).toBe(63); 76 | 77 | expect(Array.from(castles.path.white.a)).toEqual([1, 2, 3]); 78 | expect(Array.from(castles.path.white.h)).toEqual([5, 6]); 79 | expect(Array.from(castles.path.black.a)).toEqual([57, 58, 59]); 80 | expect(Array.from(castles.path.black.h)).toEqual([61, 62]); 81 | }); 82 | 83 | test('play move', () => { 84 | const pos = Chess.fromSetup(parseFen('8/8/8/5k2/3p4/8/4P3/4K3 w - -').unwrap()).unwrap(); 85 | 86 | const kd1 = pos.clone(); 87 | kd1.play({ from: 4, to: 3 }); 88 | expect(makeFen(kd1.toSetup())).toBe('8/8/8/5k2/3p4/8/4P3/3K4 b - - 1 1'); 89 | 90 | const e4 = pos.clone(); 91 | e4.play({ from: 12, to: 28 }); 92 | expect(makeFen(e4.toSetup())).toBe('8/8/8/5k2/3pP3/8/8/4K3 b - e3 0 1'); 93 | }); 94 | 95 | test('castling moves', () => { 96 | let pos = Chess.fromSetup(parseFen('2r5/8/8/8/8/8/6PP/k2KR3 w K -').unwrap()).unwrap(); 97 | let move = { from: 3, to: 4 }; 98 | expect(pos.isLegal(move)).toBe(true); 99 | pos.play(move); 100 | expect(makeFen(pos.toSetup())).toBe('2r5/8/8/8/8/8/6PP/k4RK1 b - - 1 1'); 101 | 102 | pos = Chess.fromSetup( 103 | parseFen('r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1').unwrap(), 104 | ).unwrap(); 105 | move = { from: 4, to: 0 }; 106 | expect(pos.isLegal(move)).toBe(true); 107 | pos.play(move); 108 | expect(makeFen(pos.toSetup())).toBe('r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/2KR3R b kq - 1 1'); 109 | 110 | pos = Chess.fromSetup( 111 | parseFen('1r2k2r/p1b1n1pp/1q3p2/1p2pPQ1/4P3/2P4P/1B2B1P1/R3K2R w KQk - 0 20').unwrap(), 112 | ).unwrap(); 113 | const queenSide = { from: 4, to: 0 }; 114 | const altQueenSide = { from: 4, to: 2 }; 115 | expect(castlingSide(pos, queenSide)).toBe('a'); 116 | expect(normalizeMove(pos, queenSide)).toEqual(queenSide); 117 | expect(pos.isLegal(queenSide)).toBe(true); 118 | expect(castlingSide(pos, altQueenSide)).toBe('a'); 119 | expect(normalizeMove(pos, altQueenSide)).toEqual(queenSide); 120 | expect(pos.isLegal(altQueenSide)).toBe(true); 121 | pos.play(altQueenSide); 122 | expect(makeFen(pos.toSetup())).toBe('1r2k2r/p1b1n1pp/1q3p2/1p2pPQ1/4P3/2P4P/1B2B1P1/2KR3R b k - 1 20'); 123 | }); 124 | 125 | test('test illegal promotion', () => { 126 | const pos = Chess.default(); 127 | expect(pos.isLegal({ from: 12, to: 20, promotion: 'queen' })).toBe(false); 128 | }); 129 | 130 | test('starting perft', () => { 131 | const pos = Chess.default(); 132 | expect(perft(pos, 0, false)).toBe(1); 133 | expect(perft(pos, 1, false)).toBe(20); 134 | expect(perft(pos, 2, false)).toBe(400); 135 | expect(perft(pos, 3, false)).toBe(8902); 136 | }); 137 | 138 | test.each(tricky)('tricky perft: %s: %s', (_, fen, d1, d2, d3) => { 139 | const pos = Chess.fromSetup(parseFen(fen).unwrap()).unwrap(); 140 | expect(perft(pos, 1, false)).toBe(d1); 141 | expect(perft(pos, 2, false)).toBe(d2); 142 | expect(perft(pos, 3, false)).toBe(d3); 143 | }); 144 | 145 | test.each(random)('random perft: %s: %s', (_, fen, d1, d2, d3, d4, d5) => { 146 | const pos = Chess.fromSetup(parseFen(fen).unwrap()).unwrap(); 147 | expect(perft(pos, 1, false)).toBe(d1); 148 | expect(perft(pos, 2, false)).toBe(d2); 149 | expect(perft(pos, 3, false)).toBe(d3); 150 | expect(perft(pos, 4, false)).toBe(d4); 151 | if (d5 < 100000) expect(perft(pos, 5, false)).toBe(d5); 152 | }); 153 | 154 | const insufficientMaterial: [string, boolean, boolean][] = [ 155 | ['8/5k2/8/8/8/8/3K4/8 w - - 0 1', true, true], 156 | ['8/3k4/8/8/2N5/8/3K4/8 b - - 0 1', true, true], 157 | ['8/4rk2/8/8/8/8/3K4/8 w - - 0 1', true, false], 158 | ['8/4qk2/8/8/8/8/3K4/8 w - - 0 1', true, false], 159 | ['8/4bk2/8/8/8/8/3KB3/8 w - - 0 1', false, false], 160 | ['8/8/3Q4/2bK4/B7/8/1k6/8 w - - 1 68', false, false], 161 | ['8/5k2/8/8/8/4B3/3K1B2/8 w - - 0 1', true, true], 162 | ['5K2/8/8/1B6/8/k7/6b1/8 w - - 0 39', true, true], 163 | ['8/8/8/4k3/5b2/3K4/8/2B5 w - - 0 33', true, true], 164 | ['3b4/8/8/6b1/8/8/R7/K1k5 w - - 0 1', false, true], 165 | ]; 166 | 167 | test.each(insufficientMaterial)('insufficient material: %s', (fen, white, black) => { 168 | const pos = Chess.fromSetup(parseFen(fen).unwrap()).unwrap(); 169 | expect(pos.hasInsufficientMaterial('white')).toBe(white); 170 | expect(pos.hasInsufficientMaterial('black')).toBe(black); 171 | }); 172 | 173 | test('impossible checker alignment', () => { 174 | // Multiple checkers aligned with king. 175 | const pos1 = Chess.fromSetup(parseFen('3R4/8/q4k2/2B5/1NK5/3b4/8/8 w - - 0 1').unwrap()).unwrap(); 176 | expect(isImpossibleCheck(pos1)).toBe(true); 177 | 178 | // Checkers aligned with opponent king are fine. 179 | const pos2 = Chess.fromSetup(parseFen('8/8/5k2/p1q5/PP1rp1P1/3P1N2/2RK1r2/5nN1 w - - 0 3').unwrap()).unwrap(); 180 | expect(isImpossibleCheck(pos2)).toBe(false); 181 | 182 | // En passant square aligned with checker and king. 183 | const pos3 = Chess.fromSetup(parseFen('8/8/8/1k6/3Pp3/8/8/4KQ2 b - d3 0 1').unwrap()).unwrap(); 184 | expect(isImpossibleCheck(pos3)).toBe(true); 185 | }); 186 | 187 | test('king captures unmoved rook', () => { 188 | const pos = Chess.fromSetup(parseFen('8/8/8/B2p3Q/2qPp1P1/b7/2P2PkP/4K2R b K - 0 1').unwrap()).unwrap(); 189 | const move = parseUci('g2h1')!; 190 | expect(move).toEqual({ from: 14, to: 7 }); 191 | expect(pos.isLegal(move)).toBe(true); 192 | pos.play(move); 193 | expect(makeFen(pos.toSetup())).toBe('8/8/8/B2p3Q/2qPp1P1/b7/2P2P1P/4K2k w - - 0 2'); 194 | }); 195 | 196 | test('en passant and unrelated check', () => { 197 | const setup = parseFen('rnbqk1nr/bb3p1p/1q2r3/2pPp3/3P4/7P/1PP1NpPP/R1BQKBNR w KQkq c6').unwrap(); 198 | const pos = Chess.fromSetup(setup).unwrap(); 199 | expect(isImpossibleCheck(pos)).toBe(true); 200 | const enPassant = parseUci('d5c6')!; 201 | expect(pos.isLegal(enPassant)).toBe(false); 202 | }); 203 | -------------------------------------------------------------------------------- /src/chess.ts: -------------------------------------------------------------------------------- 1 | import { Result } from '@badrap/result'; 2 | import { 3 | attacks, 4 | between, 5 | bishopAttacks, 6 | kingAttacks, 7 | knightAttacks, 8 | pawnAttacks, 9 | queenAttacks, 10 | ray, 11 | rookAttacks, 12 | } from './attacks.js'; 13 | import { Board, boardEquals } from './board.js'; 14 | import { Material, RemainingChecks, Setup } from './setup.js'; 15 | import { SquareSet } from './squareSet.js'; 16 | import { 17 | ByCastlingSide, 18 | ByColor, 19 | CASTLING_SIDES, 20 | CastlingSide, 21 | Color, 22 | COLORS, 23 | isDrop, 24 | Move, 25 | NormalMove, 26 | Outcome, 27 | Piece, 28 | Rules, 29 | Square, 30 | } from './types.js'; 31 | import { defined, kingCastlesTo, opposite, rookCastlesTo, squareRank } from './util.js'; 32 | 33 | export enum IllegalSetup { 34 | Empty = 'ERR_EMPTY', 35 | OppositeCheck = 'ERR_OPPOSITE_CHECK', 36 | PawnsOnBackrank = 'ERR_PAWNS_ON_BACKRANK', 37 | Kings = 'ERR_KINGS', 38 | Variant = 'ERR_VARIANT', 39 | } 40 | 41 | export class PositionError extends Error {} 42 | 43 | const attacksTo = (square: Square, attacker: Color, board: Board, occupied: SquareSet): SquareSet => 44 | board[attacker].intersect( 45 | rookAttacks(square, occupied) 46 | .intersect(board.rooksAndQueens()) 47 | .union(bishopAttacks(square, occupied).intersect(board.bishopsAndQueens())) 48 | .union(knightAttacks(square).intersect(board.knight)) 49 | .union(kingAttacks(square).intersect(board.king)) 50 | .union(pawnAttacks(opposite(attacker), square).intersect(board.pawn)), 51 | ); 52 | 53 | export class Castles { 54 | castlingRights: SquareSet; 55 | rook: ByColor>; 56 | path: ByColor>; 57 | 58 | private constructor() {} 59 | 60 | static default(): Castles { 61 | const castles = new Castles(); 62 | castles.castlingRights = SquareSet.corners(); 63 | castles.rook = { 64 | white: { a: 0, h: 7 }, 65 | black: { a: 56, h: 63 }, 66 | }; 67 | castles.path = { 68 | white: { a: new SquareSet(0xe, 0), h: new SquareSet(0x60, 0) }, 69 | black: { a: new SquareSet(0, 0x0e000000), h: new SquareSet(0, 0x60000000) }, 70 | }; 71 | return castles; 72 | } 73 | 74 | static empty(): Castles { 75 | const castles = new Castles(); 76 | castles.castlingRights = SquareSet.empty(); 77 | castles.rook = { 78 | white: { a: undefined, h: undefined }, 79 | black: { a: undefined, h: undefined }, 80 | }; 81 | castles.path = { 82 | white: { a: SquareSet.empty(), h: SquareSet.empty() }, 83 | black: { a: SquareSet.empty(), h: SquareSet.empty() }, 84 | }; 85 | return castles; 86 | } 87 | 88 | clone(): Castles { 89 | const castles = new Castles(); 90 | castles.castlingRights = this.castlingRights; 91 | castles.rook = { 92 | white: { a: this.rook.white.a, h: this.rook.white.h }, 93 | black: { a: this.rook.black.a, h: this.rook.black.h }, 94 | }; 95 | castles.path = { 96 | white: { a: this.path.white.a, h: this.path.white.h }, 97 | black: { a: this.path.black.a, h: this.path.black.h }, 98 | }; 99 | return castles; 100 | } 101 | 102 | private add(color: Color, side: CastlingSide, king: Square, rook: Square): void { 103 | const kingTo = kingCastlesTo(color, side); 104 | const rookTo = rookCastlesTo(color, side); 105 | this.castlingRights = this.castlingRights.with(rook); 106 | this.rook[color][side] = rook; 107 | this.path[color][side] = between(rook, rookTo) 108 | .with(rookTo) 109 | .union(between(king, kingTo).with(kingTo)) 110 | .without(king) 111 | .without(rook); 112 | } 113 | 114 | static fromSetup(setup: Setup): Castles { 115 | const castles = Castles.empty(); 116 | const rooks = setup.castlingRights.intersect(setup.board.rook); 117 | for (const color of COLORS) { 118 | const backrank = SquareSet.backrank(color); 119 | const king = setup.board.kingOf(color); 120 | if (!defined(king) || !backrank.has(king)) continue; 121 | const side = rooks.intersect(setup.board[color]).intersect(backrank); 122 | const aSide = side.first(); 123 | if (defined(aSide) && aSide < king) castles.add(color, 'a', king, aSide); 124 | const hSide = side.last(); 125 | if (defined(hSide) && king < hSide) castles.add(color, 'h', king, hSide); 126 | } 127 | return castles; 128 | } 129 | 130 | discardRook(square: Square): void { 131 | if (this.castlingRights.has(square)) { 132 | this.castlingRights = this.castlingRights.without(square); 133 | for (const color of COLORS) { 134 | for (const side of CASTLING_SIDES) { 135 | if (this.rook[color][side] === square) this.rook[color][side] = undefined; 136 | } 137 | } 138 | } 139 | } 140 | 141 | discardColor(color: Color): void { 142 | this.castlingRights = this.castlingRights.diff(SquareSet.backrank(color)); 143 | this.rook[color].a = undefined; 144 | this.rook[color].h = undefined; 145 | } 146 | } 147 | 148 | export interface Context { 149 | king: Square | undefined; 150 | blockers: SquareSet; 151 | checkers: SquareSet; 152 | variantEnd: boolean; 153 | mustCapture: boolean; 154 | } 155 | 156 | export abstract class Position { 157 | board: Board; 158 | pockets: Material | undefined; 159 | turn: Color; 160 | castles: Castles; 161 | epSquare: Square | undefined; 162 | remainingChecks: RemainingChecks | undefined; 163 | halfmoves: number; 164 | fullmoves: number; 165 | 166 | protected constructor(readonly rules: Rules) {} 167 | 168 | reset() { 169 | this.board = Board.default(); 170 | this.pockets = undefined; 171 | this.turn = 'white'; 172 | this.castles = Castles.default(); 173 | this.epSquare = undefined; 174 | this.remainingChecks = undefined; 175 | this.halfmoves = 0; 176 | this.fullmoves = 1; 177 | } 178 | 179 | protected setupUnchecked(setup: Setup) { 180 | this.board = setup.board.clone(); 181 | this.board.promoted = SquareSet.empty(); 182 | this.pockets = undefined; 183 | this.turn = setup.turn; 184 | this.castles = Castles.fromSetup(setup); 185 | this.epSquare = validEpSquare(this, setup.epSquare); 186 | this.remainingChecks = undefined; 187 | this.halfmoves = setup.halfmoves; 188 | this.fullmoves = setup.fullmoves; 189 | } 190 | 191 | // When subclassing overwrite at least: 192 | // 193 | // - static default() 194 | // - static fromSetup() 195 | // - static clone() 196 | // 197 | // - dests() 198 | // - isVariantEnd() 199 | // - variantOutcome() 200 | // - hasInsufficientMaterial() 201 | // - isStandardMaterial() 202 | 203 | kingAttackers(square: Square, attacker: Color, occupied: SquareSet): SquareSet { 204 | return attacksTo(square, attacker, this.board, occupied); 205 | } 206 | 207 | protected playCaptureAt(square: Square, captured: Piece): void { 208 | this.halfmoves = 0; 209 | if (captured.role === 'rook') this.castles.discardRook(square); 210 | if (this.pockets) this.pockets[opposite(captured.color)][captured.promoted ? 'pawn' : captured.role]++; 211 | } 212 | 213 | ctx(): Context { 214 | const variantEnd = this.isVariantEnd(); 215 | const king = this.board.kingOf(this.turn); 216 | if (!defined(king)) { 217 | return { king, blockers: SquareSet.empty(), checkers: SquareSet.empty(), variantEnd, mustCapture: false }; 218 | } 219 | const snipers = rookAttacks(king, SquareSet.empty()) 220 | .intersect(this.board.rooksAndQueens()) 221 | .union(bishopAttacks(king, SquareSet.empty()).intersect(this.board.bishopsAndQueens())) 222 | .intersect(this.board[opposite(this.turn)]); 223 | let blockers = SquareSet.empty(); 224 | for (const sniper of snipers) { 225 | const b = between(king, sniper).intersect(this.board.occupied); 226 | if (!b.moreThanOne()) blockers = blockers.union(b); 227 | } 228 | const checkers = this.kingAttackers(king, opposite(this.turn), this.board.occupied); 229 | return { 230 | king, 231 | blockers, 232 | checkers, 233 | variantEnd, 234 | mustCapture: false, 235 | }; 236 | } 237 | 238 | clone(): Position { 239 | const pos = new (this as any).constructor(); 240 | pos.board = this.board.clone(); 241 | pos.pockets = this.pockets?.clone(); 242 | pos.turn = this.turn; 243 | pos.castles = this.castles.clone(); 244 | pos.epSquare = this.epSquare; 245 | pos.remainingChecks = this.remainingChecks?.clone(); 246 | pos.halfmoves = this.halfmoves; 247 | pos.fullmoves = this.fullmoves; 248 | return pos; 249 | } 250 | 251 | protected validate(): Result { 252 | if (this.board.occupied.isEmpty()) return Result.err(new PositionError(IllegalSetup.Empty)); 253 | if (this.board.king.size() !== 2) return Result.err(new PositionError(IllegalSetup.Kings)); 254 | 255 | if (!defined(this.board.kingOf(this.turn))) return Result.err(new PositionError(IllegalSetup.Kings)); 256 | 257 | const otherKing = this.board.kingOf(opposite(this.turn)); 258 | if (!defined(otherKing)) return Result.err(new PositionError(IllegalSetup.Kings)); 259 | if (this.kingAttackers(otherKing, this.turn, this.board.occupied).nonEmpty()) { 260 | return Result.err(new PositionError(IllegalSetup.OppositeCheck)); 261 | } 262 | 263 | if (SquareSet.backranks().intersects(this.board.pawn)) { 264 | return Result.err(new PositionError(IllegalSetup.PawnsOnBackrank)); 265 | } 266 | 267 | return Result.ok(undefined); 268 | } 269 | 270 | dropDests(_ctx?: Context): SquareSet { 271 | return SquareSet.empty(); 272 | } 273 | 274 | dests(square: Square, ctx?: Context): SquareSet { 275 | ctx = ctx || this.ctx(); 276 | if (ctx.variantEnd) return SquareSet.empty(); 277 | const piece = this.board.get(square); 278 | if (!piece || piece.color !== this.turn) return SquareSet.empty(); 279 | 280 | let pseudo, legal; 281 | if (piece.role === 'pawn') { 282 | pseudo = pawnAttacks(this.turn, square).intersect(this.board[opposite(this.turn)]); 283 | const delta = this.turn === 'white' ? 8 : -8; 284 | const step = square + delta; 285 | if (0 <= step && step < 64 && !this.board.occupied.has(step)) { 286 | pseudo = pseudo.with(step); 287 | const canDoubleStep = this.turn === 'white' ? square < 16 : square >= 64 - 16; 288 | const doubleStep = step + delta; 289 | if (canDoubleStep && !this.board.occupied.has(doubleStep)) { 290 | pseudo = pseudo.with(doubleStep); 291 | } 292 | } 293 | if (defined(this.epSquare) && canCaptureEp(this, square, ctx)) { 294 | legal = SquareSet.fromSquare(this.epSquare); 295 | } 296 | } else if (piece.role === 'bishop') pseudo = bishopAttacks(square, this.board.occupied); 297 | else if (piece.role === 'knight') pseudo = knightAttacks(square); 298 | else if (piece.role === 'rook') pseudo = rookAttacks(square, this.board.occupied); 299 | else if (piece.role === 'queen') pseudo = queenAttacks(square, this.board.occupied); 300 | else pseudo = kingAttacks(square); 301 | 302 | pseudo = pseudo.diff(this.board[this.turn]); 303 | 304 | if (defined(ctx.king)) { 305 | if (piece.role === 'king') { 306 | const occ = this.board.occupied.without(square); 307 | for (const to of pseudo) { 308 | if (this.kingAttackers(to, opposite(this.turn), occ).nonEmpty()) pseudo = pseudo.without(to); 309 | } 310 | return pseudo.union(castlingDest(this, 'a', ctx)).union(castlingDest(this, 'h', ctx)); 311 | } 312 | 313 | if (ctx.checkers.nonEmpty()) { 314 | const checker = ctx.checkers.singleSquare(); 315 | if (!defined(checker)) return SquareSet.empty(); 316 | pseudo = pseudo.intersect(between(checker, ctx.king).with(checker)); 317 | } 318 | 319 | if (ctx.blockers.has(square)) pseudo = pseudo.intersect(ray(square, ctx.king)); 320 | } 321 | 322 | if (legal) pseudo = pseudo.union(legal); 323 | return pseudo; 324 | } 325 | 326 | isVariantEnd(): boolean { 327 | return false; 328 | } 329 | 330 | variantOutcome(_ctx?: Context): Outcome | undefined { 331 | return; 332 | } 333 | 334 | hasInsufficientMaterial(color: Color): boolean { 335 | if (this.board[color].intersect(this.board.pawn.union(this.board.rooksAndQueens())).nonEmpty()) return false; 336 | if (this.board[color].intersects(this.board.knight)) { 337 | return ( 338 | this.board[color].size() <= 2 339 | && this.board[opposite(color)].diff(this.board.king).diff(this.board.queen).isEmpty() 340 | ); 341 | } 342 | if (this.board[color].intersects(this.board.bishop)) { 343 | const sameColor = !this.board.bishop.intersects(SquareSet.darkSquares()) 344 | || !this.board.bishop.intersects(SquareSet.lightSquares()); 345 | return sameColor && this.board.pawn.isEmpty() && this.board.knight.isEmpty(); 346 | } 347 | return true; 348 | } 349 | 350 | // The following should be identical in all subclasses 351 | 352 | toSetup(): Setup { 353 | return { 354 | board: this.board.clone(), 355 | pockets: this.pockets?.clone(), 356 | turn: this.turn, 357 | castlingRights: this.castles.castlingRights, 358 | epSquare: legalEpSquare(this), 359 | remainingChecks: this.remainingChecks?.clone(), 360 | halfmoves: Math.min(this.halfmoves, 150), 361 | fullmoves: Math.min(Math.max(this.fullmoves, 1), 9999), 362 | }; 363 | } 364 | 365 | isInsufficientMaterial(): boolean { 366 | return COLORS.every(color => this.hasInsufficientMaterial(color)); 367 | } 368 | 369 | hasDests(ctx?: Context): boolean { 370 | ctx = ctx || this.ctx(); 371 | for (const square of this.board[this.turn]) { 372 | if (this.dests(square, ctx).nonEmpty()) return true; 373 | } 374 | return this.dropDests(ctx).nonEmpty(); 375 | } 376 | 377 | isLegal(move: Move, ctx?: Context): boolean { 378 | if (isDrop(move)) { 379 | if (!this.pockets || this.pockets[this.turn][move.role] <= 0) return false; 380 | if (move.role === 'pawn' && SquareSet.backranks().has(move.to)) return false; 381 | return this.dropDests(ctx).has(move.to); 382 | } else { 383 | if (move.promotion === 'pawn') return false; 384 | if (move.promotion === 'king' && this.rules !== 'antichess') return false; 385 | if (!!move.promotion !== (this.board.pawn.has(move.from) && SquareSet.backranks().has(move.to))) return false; 386 | const dests = this.dests(move.from, ctx); 387 | return dests.has(move.to) || dests.has(normalizeMove(this, move).to); 388 | } 389 | } 390 | 391 | isCheck(): boolean { 392 | const king = this.board.kingOf(this.turn); 393 | return defined(king) && this.kingAttackers(king, opposite(this.turn), this.board.occupied).nonEmpty(); 394 | } 395 | 396 | isEnd(ctx?: Context): boolean { 397 | if (ctx ? ctx.variantEnd : this.isVariantEnd()) return true; 398 | return this.isInsufficientMaterial() || !this.hasDests(ctx); 399 | } 400 | 401 | isCheckmate(ctx?: Context): boolean { 402 | ctx = ctx || this.ctx(); 403 | return !ctx.variantEnd && ctx.checkers.nonEmpty() && !this.hasDests(ctx); 404 | } 405 | 406 | isStalemate(ctx?: Context): boolean { 407 | ctx = ctx || this.ctx(); 408 | return !ctx.variantEnd && ctx.checkers.isEmpty() && !this.hasDests(ctx); 409 | } 410 | 411 | outcome(ctx?: Context): Outcome | undefined { 412 | const variantOutcome = this.variantOutcome(ctx); 413 | if (variantOutcome) return variantOutcome; 414 | ctx = ctx || this.ctx(); 415 | if (this.isCheckmate(ctx)) return { winner: opposite(this.turn) }; 416 | else if (this.isInsufficientMaterial() || this.isStalemate(ctx)) return { winner: undefined }; 417 | else return; 418 | } 419 | 420 | allDests(ctx?: Context): Map { 421 | ctx = ctx || this.ctx(); 422 | const d = new Map(); 423 | if (ctx.variantEnd) return d; 424 | for (const square of this.board[this.turn]) { 425 | d.set(square, this.dests(square, ctx)); 426 | } 427 | return d; 428 | } 429 | 430 | play(move: Move): void { 431 | const turn = this.turn; 432 | const epSquare = this.epSquare; 433 | const castling = castlingSide(this, move); 434 | 435 | this.epSquare = undefined; 436 | this.halfmoves += 1; 437 | if (turn === 'black') this.fullmoves += 1; 438 | this.turn = opposite(turn); 439 | 440 | if (isDrop(move)) { 441 | this.board.set(move.to, { role: move.role, color: turn }); 442 | if (this.pockets) this.pockets[turn][move.role]--; 443 | if (move.role === 'pawn') this.halfmoves = 0; 444 | } else { 445 | const piece = this.board.take(move.from); 446 | if (!piece) return; 447 | 448 | let epCapture: Piece | undefined; 449 | if (piece.role === 'pawn') { 450 | this.halfmoves = 0; 451 | if (move.to === epSquare) { 452 | epCapture = this.board.take(move.to + (turn === 'white' ? -8 : 8)); 453 | } 454 | const delta = move.from - move.to; 455 | if (Math.abs(delta) === 16 && 8 <= move.from && move.from <= 55) { 456 | this.epSquare = (move.from + move.to) >> 1; 457 | } 458 | if (move.promotion) { 459 | piece.role = move.promotion; 460 | piece.promoted = !!this.pockets; 461 | } 462 | } else if (piece.role === 'rook') { 463 | this.castles.discardRook(move.from); 464 | } else if (piece.role === 'king') { 465 | if (castling) { 466 | const rookFrom = this.castles.rook[turn][castling]; 467 | if (defined(rookFrom)) { 468 | const rook = this.board.take(rookFrom); 469 | this.board.set(kingCastlesTo(turn, castling), piece); 470 | if (rook) this.board.set(rookCastlesTo(turn, castling), rook); 471 | } 472 | } 473 | this.castles.discardColor(turn); 474 | } 475 | 476 | if (!castling) { 477 | const capture = this.board.set(move.to, piece) || epCapture; 478 | if (capture) this.playCaptureAt(move.to, capture); 479 | } 480 | } 481 | 482 | if (this.remainingChecks) { 483 | if (this.isCheck()) this.remainingChecks[turn] = Math.max(this.remainingChecks[turn] - 1, 0); 484 | } 485 | } 486 | } 487 | 488 | export class Chess extends Position { 489 | private constructor() { 490 | super('chess'); 491 | } 492 | 493 | static default(): Chess { 494 | const pos = new this(); 495 | pos.reset(); 496 | return pos; 497 | } 498 | 499 | static fromSetup(setup: Setup): Result { 500 | const pos = new this(); 501 | pos.setupUnchecked(setup); 502 | return pos.validate().map(_ => pos); 503 | } 504 | 505 | clone(): Chess { 506 | return super.clone() as Chess; 507 | } 508 | } 509 | 510 | const validEpSquare = (pos: Position, square: Square | undefined): Square | undefined => { 511 | if (!defined(square)) return; 512 | const epRank = pos.turn === 'white' ? 5 : 2; 513 | const forward = pos.turn === 'white' ? 8 : -8; 514 | if (squareRank(square) !== epRank) return; 515 | if (pos.board.occupied.has(square + forward)) return; 516 | const pawn = square - forward; 517 | if (!pos.board.pawn.has(pawn) || !pos.board[opposite(pos.turn)].has(pawn)) return; 518 | return square; 519 | }; 520 | 521 | const legalEpSquare = (pos: Position): Square | undefined => { 522 | if (!defined(pos.epSquare)) return; 523 | const ctx = pos.ctx(); 524 | const ourPawns = pos.board.pieces(pos.turn, 'pawn'); 525 | const candidates = ourPawns.intersect(pawnAttacks(opposite(pos.turn), pos.epSquare)); 526 | for (const candidate of candidates) { 527 | if (pos.dests(candidate, ctx).has(pos.epSquare)) return pos.epSquare; 528 | } 529 | return; 530 | }; 531 | 532 | const canCaptureEp = (pos: Position, pawnFrom: Square, ctx: Context): boolean => { 533 | if (!defined(pos.epSquare)) return false; 534 | if (!pawnAttacks(pos.turn, pawnFrom).has(pos.epSquare)) return false; 535 | if (!defined(ctx.king)) return true; 536 | const delta = pos.turn === 'white' ? 8 : -8; 537 | const captured = pos.epSquare - delta; 538 | return pos 539 | .kingAttackers( 540 | ctx.king, 541 | opposite(pos.turn), 542 | pos.board.occupied.toggle(pawnFrom).toggle(captured).with(pos.epSquare), 543 | ) 544 | .without(captured) 545 | .isEmpty(); 546 | }; 547 | 548 | const castlingDest = (pos: Position, side: CastlingSide, ctx: Context): SquareSet => { 549 | if (!defined(ctx.king) || ctx.checkers.nonEmpty()) return SquareSet.empty(); 550 | const rook = pos.castles.rook[pos.turn][side]; 551 | if (!defined(rook)) return SquareSet.empty(); 552 | if (pos.castles.path[pos.turn][side].intersects(pos.board.occupied)) return SquareSet.empty(); 553 | 554 | const kingTo = kingCastlesTo(pos.turn, side); 555 | const kingPath = between(ctx.king, kingTo); 556 | const occ = pos.board.occupied.without(ctx.king); 557 | for (const sq of kingPath) { 558 | if (pos.kingAttackers(sq, opposite(pos.turn), occ).nonEmpty()) return SquareSet.empty(); 559 | } 560 | 561 | const rookTo = rookCastlesTo(pos.turn, side); 562 | const after = pos.board.occupied.toggle(ctx.king).toggle(rook).toggle(rookTo); 563 | if (pos.kingAttackers(kingTo, opposite(pos.turn), after).nonEmpty()) return SquareSet.empty(); 564 | 565 | return SquareSet.fromSquare(rook); 566 | }; 567 | 568 | export const pseudoDests = (pos: Position, square: Square, ctx: Context): SquareSet => { 569 | if (ctx.variantEnd) return SquareSet.empty(); 570 | const piece = pos.board.get(square); 571 | if (!piece || piece.color !== pos.turn) return SquareSet.empty(); 572 | 573 | let pseudo = attacks(piece, square, pos.board.occupied); 574 | if (piece.role === 'pawn') { 575 | let captureTargets = pos.board[opposite(pos.turn)]; 576 | if (defined(pos.epSquare)) captureTargets = captureTargets.with(pos.epSquare); 577 | pseudo = pseudo.intersect(captureTargets); 578 | const delta = pos.turn === 'white' ? 8 : -8; 579 | const step = square + delta; 580 | if (0 <= step && step < 64 && !pos.board.occupied.has(step)) { 581 | pseudo = pseudo.with(step); 582 | const canDoubleStep = pos.turn === 'white' ? square < 16 : square >= 64 - 16; 583 | const doubleStep = step + delta; 584 | if (canDoubleStep && !pos.board.occupied.has(doubleStep)) { 585 | pseudo = pseudo.with(doubleStep); 586 | } 587 | } 588 | return pseudo; 589 | } else { 590 | pseudo = pseudo.diff(pos.board[pos.turn]); 591 | } 592 | if (square === ctx.king) return pseudo.union(castlingDest(pos, 'a', ctx)).union(castlingDest(pos, 'h', ctx)); 593 | else return pseudo; 594 | }; 595 | 596 | export const equalsIgnoreMoves = (left: Position, right: Position): boolean => 597 | left.rules === right.rules 598 | && boardEquals(left.board, right.board) 599 | && ((right.pockets && left.pockets?.equals(right.pockets)) || (!left.pockets && !right.pockets)) 600 | && left.turn === right.turn 601 | && left.castles.castlingRights.equals(right.castles.castlingRights) 602 | && legalEpSquare(left) === legalEpSquare(right) 603 | && ((right.remainingChecks && left.remainingChecks?.equals(right.remainingChecks)) 604 | || (!left.remainingChecks && !right.remainingChecks)); 605 | 606 | export const castlingSide = (pos: Position, move: Move): CastlingSide | undefined => { 607 | if (isDrop(move)) return; 608 | const delta = move.to - move.from; 609 | if (Math.abs(delta) !== 2 && !pos.board[pos.turn].has(move.to)) return; 610 | if (!pos.board.king.has(move.from)) return; 611 | return delta > 0 ? 'h' : 'a'; 612 | }; 613 | 614 | export const normalizeMove = (pos: Position, move: Move): Move => { 615 | const side = castlingSide(pos, move); 616 | if (!side) return move; 617 | const rookFrom = pos.castles.rook[pos.turn][side]; 618 | return { 619 | from: (move as NormalMove).from, 620 | to: defined(rookFrom) ? rookFrom : move.to, 621 | }; 622 | }; 623 | 624 | export const isStandardMaterialSide = (board: Board, color: Color): boolean => { 625 | const promoted = Math.max(board.pieces(color, 'queen').size() - 1, 0) 626 | + Math.max(board.pieces(color, 'rook').size() - 2, 0) 627 | + Math.max(board.pieces(color, 'knight').size() - 2, 0) 628 | + Math.max(board.pieces(color, 'bishop').intersect(SquareSet.lightSquares()).size() - 1, 0) 629 | + Math.max(board.pieces(color, 'bishop').intersect(SquareSet.darkSquares()).size() - 1, 0); 630 | return board.pieces(color, 'pawn').size() + promoted <= 8; 631 | }; 632 | 633 | export const isStandardMaterial = (pos: Chess): boolean => 634 | COLORS.every(color => isStandardMaterialSide(pos.board, color)); 635 | 636 | export const isImpossibleCheck = (pos: Position): boolean => { 637 | const ourKing = pos.board.kingOf(pos.turn); 638 | if (!defined(ourKing)) return false; 639 | const checkers = pos.kingAttackers(ourKing, opposite(pos.turn), pos.board.occupied); 640 | if (checkers.isEmpty()) return false; 641 | if (defined(pos.epSquare)) { 642 | // The pushed pawn must be the only checker, or it has uncovered 643 | // check by a single sliding piece. 644 | const pushedTo = pos.epSquare ^ 8; 645 | const pushedFrom = pos.epSquare ^ 24; 646 | return ( 647 | checkers.moreThanOne() 648 | || (checkers.first()! !== pushedTo 649 | && pos 650 | .kingAttackers(ourKing, opposite(pos.turn), pos.board.occupied.without(pushedTo).with(pushedFrom)) 651 | .nonEmpty()) 652 | ); 653 | } else if (pos.rules === 'atomic') { 654 | // Other king moving away can cause many checks to be given at the same 655 | // time. Not checking details, or even that the king is close enough. 656 | return false; 657 | } else { 658 | // Sliding checkers aligned with king. 659 | return checkers.size() > 2 || (checkers.size() === 2 && ray(checkers.first()!, checkers.last()!).has(ourKing)); 660 | } 661 | }; 662 | -------------------------------------------------------------------------------- /src/compat.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@jest/globals'; 2 | import { Chess } from './chess.js'; 3 | import { chessgroundDests, scalachessCharPair } from './compat.js'; 4 | import { parseFen } from './fen.js'; 5 | import { parseUci } from './util.js'; 6 | 7 | test('chessground dests with Kh8', () => { 8 | const setup = parseFen('r1bq1r2/3n2k1/p1p1pp2/3pP2P/8/PPNB2Q1/2P2P2/R3K3 b Q - 1 22').unwrap(); 9 | const pos = Chess.fromSetup(setup).unwrap(); 10 | const dests = chessgroundDests(pos); 11 | expect(dests.get('g7')).toContain('h8'); 12 | expect(dests.get('g7')).not.toContain('g8'); 13 | }); 14 | 15 | test('chessground dests with regular castle', () => { 16 | const setup = parseFen('r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1').unwrap(); 17 | const wtm = Chess.fromSetup(setup).unwrap(); 18 | expect(chessgroundDests(wtm).get('e1')!.sort()).toEqual(['a1', 'c1', 'd1', 'd2', 'e2', 'f1', 'f2', 'g1', 'h1']); 19 | expect(chessgroundDests(wtm).get('e8')).toBeUndefined(); 20 | 21 | setup.turn = 'black'; 22 | const btm = Chess.fromSetup(setup).unwrap(); 23 | expect(chessgroundDests(btm).get('e8')!.sort()).toEqual(['a8', 'c8', 'd7', 'd8', 'e7', 'f7', 'f8', 'g8', 'h8']); 24 | expect(chessgroundDests(btm).get('e1')).toBeUndefined(); 25 | }); 26 | 27 | test('chessground dests with chess960 castle', () => { 28 | const setup = parseFen('rk2r3/pppbnppp/3p2n1/P2Pp3/4P2q/R5NP/1PP2PP1/1KNQRB2 b Kkq - 0 1').unwrap(); 29 | const pos = Chess.fromSetup(setup).unwrap(); 30 | expect(chessgroundDests(pos).get('b8')!.sort()).toEqual(['a8', 'c8', 'e8']); 31 | }); 32 | 33 | test('uci char pair', () => { 34 | // regular moves 35 | expect(scalachessCharPair(parseUci('a1b1')!)).toBe('#$'); 36 | expect(scalachessCharPair(parseUci('a1a2')!)).toBe('#+'); 37 | expect(scalachessCharPair(parseUci('h7h8')!)).toBe('Zb'); 38 | 39 | // promotions 40 | expect(scalachessCharPair(parseUci('b7b8q')!)).toBe('Td'); 41 | expect(scalachessCharPair(parseUci('b7c8q')!)).toBe('Te'); 42 | expect(scalachessCharPair(parseUci('b7c8n')!)).toBe('T}'); 43 | 44 | // drops 45 | expect(scalachessCharPair(parseUci('P@a1')!)).toBe('#\x8f'); 46 | expect(scalachessCharPair(parseUci('Q@h8')!)).toBe('b\x8b'); 47 | }); 48 | -------------------------------------------------------------------------------- /src/compat.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Compatibility with other libraries. 3 | * 4 | * Convert between the formats used by chessops, 5 | * [chessground](https://github.com/lichess-org/chessground), 6 | * and [scalachess](https://github.com/lichess-org/scalachess). 7 | * 8 | * @packageDocumentation 9 | */ 10 | 11 | import { Position } from './chess.js'; 12 | import { isDrop, Move, Rules, SquareName } from './types.js'; 13 | import { makeSquare, squareFile } from './util.js'; 14 | 15 | export interface ChessgroundDestsOpts { 16 | chess960?: boolean; 17 | } 18 | 19 | /** 20 | * Computes the legal move destinations in the format used by chessground. 21 | * 22 | * Includes both possible representations of castling moves (unless 23 | * `chess960` mode is enabled), so that the `rookCastles` option will work 24 | * correctly. 25 | */ 26 | export const chessgroundDests = (pos: Position, opts?: ChessgroundDestsOpts): Map => { 27 | const result = new Map(); 28 | const ctx = pos.ctx(); 29 | for (const [from, squares] of pos.allDests(ctx)) { 30 | if (squares.nonEmpty()) { 31 | const d = Array.from(squares, makeSquare); 32 | if (!opts?.chess960 && from === ctx.king && squareFile(from) === 4) { 33 | // Chessground needs both types of castling dests and filters based on 34 | // a rookCastles setting. 35 | if (squares.has(0)) d.push('c1'); 36 | else if (squares.has(56)) d.push('c8'); 37 | if (squares.has(7)) d.push('g1'); 38 | else if (squares.has(63)) d.push('g8'); 39 | } 40 | result.set(makeSquare(from), d); 41 | } 42 | } 43 | return result; 44 | }; 45 | 46 | export const chessgroundMove = (move: Move): SquareName[] => 47 | isDrop(move) ? [makeSquare(move.to)] : [makeSquare(move.from), makeSquare(move.to)]; 48 | 49 | export const scalachessCharPair = (move: Move): string => 50 | isDrop(move) 51 | ? String.fromCharCode( 52 | 35 + move.to, 53 | 35 + 64 + 8 * 5 + ['queen', 'rook', 'bishop', 'knight', 'pawn'].indexOf(move.role), 54 | ) 55 | : String.fromCharCode( 56 | 35 + move.from, 57 | move.promotion 58 | ? 35 + 64 + 8 * ['queen', 'rook', 'bishop', 'knight', 'king'].indexOf(move.promotion) + squareFile(move.to) 59 | : 35 + move.to, 60 | ); 61 | 62 | export const lichessRules = ( 63 | variant: 64 | | 'standard' 65 | | 'chess960' 66 | | 'antichess' 67 | | 'fromPosition' 68 | | 'kingOfTheHill' 69 | | 'threeCheck' 70 | | 'atomic' 71 | | 'horde' 72 | | 'racingKings' 73 | | 'crazyhouse', 74 | ): Rules => { 75 | switch (variant) { 76 | case 'standard': 77 | case 'chess960': 78 | case 'fromPosition': 79 | return 'chess'; 80 | case 'threeCheck': 81 | return '3check'; 82 | case 'kingOfTheHill': 83 | return 'kingofthehill'; 84 | case 'racingKings': 85 | return 'racingkings'; 86 | default: 87 | return variant; 88 | } 89 | }; 90 | 91 | export const lichessVariant = ( 92 | rules: Rules, 93 | ): 'standard' | 'antichess' | 'kingOfTheHill' | 'threeCheck' | 'atomic' | 'horde' | 'racingKings' | 'crazyhouse' => { 94 | switch (rules) { 95 | case 'chess': 96 | return 'standard'; 97 | case '3check': 98 | return 'threeCheck'; 99 | case 'kingofthehill': 100 | return 'kingOfTheHill'; 101 | case 'racingkings': 102 | return 'racingKings'; 103 | default: 104 | return rules; 105 | } 106 | }; 107 | -------------------------------------------------------------------------------- /src/debug.ts: -------------------------------------------------------------------------------- 1 | import { Board } from './board.js'; 2 | import { Position } from './chess.js'; 3 | import { makePiece } from './fen.js'; 4 | import { SquareSet } from './squareSet.js'; 5 | import { Piece, Role, ROLES, Square } from './types.js'; 6 | import { makeSquare, makeUci, opposite, squareRank } from './util.js'; 7 | 8 | export const squareSet = (squares: SquareSet): string => { 9 | const r = []; 10 | for (let y = 7; y >= 0; y--) { 11 | for (let x = 0; x < 8; x++) { 12 | const square = x + y * 8; 13 | r.push(squares.has(square) ? '1' : '.'); 14 | r.push(x < 7 ? ' ' : '\n'); 15 | } 16 | } 17 | return r.join(''); 18 | }; 19 | 20 | export const piece = (piece: Piece): string => makePiece(piece); 21 | 22 | export const board = (board: Board): string => { 23 | const r = []; 24 | for (let y = 7; y >= 0; y--) { 25 | for (let x = 0; x < 8; x++) { 26 | const square = x + y * 8; 27 | const p = board.get(square); 28 | const col = p ? piece(p) : '.'; 29 | r.push(col); 30 | r.push(x < 7 ? (col.length < 2 ? ' ' : '') : '\n'); 31 | } 32 | } 33 | return r.join(''); 34 | }; 35 | 36 | export const square = (sq: Square): string => makeSquare(sq); 37 | 38 | export const dests = (dests: Map): string => { 39 | const lines = []; 40 | for (const [from, to] of dests) { 41 | lines.push(`${makeSquare(from)}: ${Array.from(to, square).join(' ')}`); 42 | } 43 | return lines.join('\n'); 44 | }; 45 | 46 | export const perft = (pos: Position, depth: number, log = false): number => { 47 | if (depth < 1) return 1; 48 | 49 | const promotionRoles: Role[] = ['queen', 'knight', 'rook', 'bishop']; 50 | if (pos.rules === 'antichess') promotionRoles.push('king'); 51 | 52 | const ctx = pos.ctx(); 53 | const dropDests = pos.dropDests(ctx); 54 | 55 | if (!log && depth === 1 && dropDests.isEmpty()) { 56 | // Optimization for leaf nodes. 57 | let nodes = 0; 58 | for (const [from, to] of pos.allDests(ctx)) { 59 | nodes += to.size(); 60 | if (pos.board.pawn.has(from)) { 61 | const backrank = SquareSet.backrank(opposite(pos.turn)); 62 | nodes += to.intersect(backrank).size() * (promotionRoles.length - 1); 63 | } 64 | } 65 | return nodes; 66 | } else { 67 | let nodes = 0; 68 | for (const [from, dests] of pos.allDests(ctx)) { 69 | const promotions: Array = 70 | squareRank(from) === (pos.turn === 'white' ? 6 : 1) && pos.board.pawn.has(from) ? promotionRoles : [undefined]; 71 | for (const to of dests) { 72 | for (const promotion of promotions) { 73 | const child = pos.clone(); 74 | const move = { from, to, promotion }; 75 | child.play(move); 76 | const children = perft(child, depth - 1, false); 77 | if (log) console.log(makeUci(move), children); 78 | nodes += children; 79 | } 80 | } 81 | } 82 | if (pos.pockets) { 83 | for (const role of ROLES) { 84 | if (pos.pockets[pos.turn][role] > 0) { 85 | for (const to of role === 'pawn' ? dropDests.diff(SquareSet.backranks()) : dropDests) { 86 | const child = pos.clone(); 87 | const move = { role, to }; 88 | child.play(move); 89 | const children = perft(child, depth - 1, false); 90 | if (log) console.log(makeUci(move), children); 91 | nodes += children; 92 | } 93 | } 94 | } 95 | } 96 | return nodes; 97 | } 98 | }; 99 | -------------------------------------------------------------------------------- /src/fen.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@jest/globals'; 2 | import { Board } from './board.js'; 3 | import { EMPTY_BOARD_FEN, INITIAL_BOARD_FEN, INITIAL_FEN, makeBoardFen, makeFen, parseFen } from './fen.js'; 4 | import { defaultSetup } from './setup.js'; 5 | import { SquareSet } from './squareSet.js'; 6 | 7 | test('make board fen', () => { 8 | expect(makeBoardFen(Board.default())).toEqual(INITIAL_BOARD_FEN); 9 | expect(makeBoardFen(Board.empty())).toEqual(EMPTY_BOARD_FEN); 10 | }); 11 | 12 | test('make initial fen', () => { 13 | expect(makeFen(defaultSetup())).toEqual(INITIAL_FEN); 14 | }); 15 | 16 | test('parse initial fen', () => { 17 | const setup = parseFen(INITIAL_FEN).unwrap(); 18 | expect(setup.board).toEqual(Board.default()); 19 | expect(setup.pockets).toBeUndefined(); 20 | expect(setup.turn).toEqual('white'); 21 | expect(setup.castlingRights).toEqual(SquareSet.corners()); 22 | expect(setup.epSquare).toBeUndefined(); 23 | expect(setup.remainingChecks).toBeUndefined(); 24 | expect(setup.halfmoves).toEqual(0); 25 | expect(setup.fullmoves).toEqual(1); 26 | }); 27 | 28 | test('partial fen', () => { 29 | const setup = parseFen(INITIAL_BOARD_FEN).unwrap(); 30 | expect(setup.board).toEqual(Board.default()); 31 | expect(setup.pockets).toBeUndefined(); 32 | expect(setup.turn).toEqual('white'); 33 | expect(setup.castlingRights).toEqual(SquareSet.empty()); 34 | expect(setup.epSquare).toBeUndefined(); 35 | expect(setup.remainingChecks).toBeUndefined(); 36 | expect(setup.halfmoves).toEqual(0); 37 | expect(setup.fullmoves).toEqual(1); 38 | }); 39 | 40 | test('invalid fen', () => { 41 | expect(parseFen('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQQKBNR w cq - 0P1').isErr).toBe(true); 42 | expect(parseFen('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - 0 1').isErr).toBe(true); 43 | expect(parseFen('4k2r/8/8/8/8/8/8/RR2K2R w KBQk - 0 1').isErr).toBe(true); 44 | }); 45 | 46 | test.each([ 47 | '8/8/8/8/8/8/8/8 w - - 1+2 12 42', 48 | '8/8/8/8/8/8/8/8[Q] b - - 0 1', 49 | 'r3k2r/8/8/8/8/8/8/R3K2R[] w Qkq - 0 1', 50 | 'r3kb1r/p1pN1ppp/2p1p3/8/2Pn4/3Q4/PP3PPP/R1B2q~K1[] w kq - 0 1', 51 | 'rQ~q1kb1r/pp2pppp/2p5/8/3P1Bb1/4PN2/PPP3PP/R2QKB1R[NNpn] b KQkq - 0 9', 52 | 'rnb1kbnr/ppp1pppp/2Pp2PP/1P3PPP/PPP1PPPP/PPP1PPPP/PPP1PPP1/PPPqPP2 w kq - 0 1', 53 | '5b1r/1p5p/4ppp1/4Bn2/1PPP1PP1/4P2P/3k4/4K2R w K - 1 1', 54 | 'rnbqkb1r/p1p1nppp/2Pp4/3P1PP1/PPPPPP1P/PPP1PPPP/PPPnbqkb/PPPPPPPP w ha - 1 6', 55 | 'rnbNRbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQhb - 2 3', 56 | ])('parse and make fen', fen => { 57 | const setup = parseFen(fen).unwrap(); 58 | expect(makeFen(setup)).toEqual(fen); 59 | }); 60 | -------------------------------------------------------------------------------- /src/fen.ts: -------------------------------------------------------------------------------- 1 | import { Result } from '@badrap/result'; 2 | import { Board } from './board.js'; 3 | import { Material, MaterialSide, RemainingChecks, Setup } from './setup.js'; 4 | import { SquareSet } from './squareSet.js'; 5 | import { Color, COLORS, FILE_NAMES, Piece, ROLES, Square } from './types.js'; 6 | import { charToRole, defined, makeSquare, parseSquare, roleToChar, squareFile, squareFromCoords } from './util.js'; 7 | 8 | export const INITIAL_BOARD_FEN = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR'; 9 | export const INITIAL_EPD = INITIAL_BOARD_FEN + ' w KQkq -'; 10 | export const INITIAL_FEN = INITIAL_EPD + ' 0 1'; 11 | export const EMPTY_BOARD_FEN = '8/8/8/8/8/8/8/8'; 12 | export const EMPTY_EPD = EMPTY_BOARD_FEN + ' w - -'; 13 | export const EMPTY_FEN = EMPTY_EPD + ' 0 1'; 14 | 15 | export enum InvalidFen { 16 | Fen = 'ERR_FEN', 17 | Board = 'ERR_BOARD', 18 | Pockets = 'ERR_POCKETS', 19 | Turn = 'ERR_TURN', 20 | Castling = 'ERR_CASTLING', 21 | EpSquare = 'ERR_EP_SQUARE', 22 | RemainingChecks = 'ERR_REMAINING_CHECKS', 23 | Halfmoves = 'ERR_HALFMOVES', 24 | Fullmoves = 'ERR_FULLMOVES', 25 | } 26 | 27 | export class FenError extends Error {} 28 | 29 | const nthIndexOf = (haystack: string, needle: string, n: number): number => { 30 | let index = haystack.indexOf(needle); 31 | while (n-- > 0) { 32 | if (index === -1) break; 33 | index = haystack.indexOf(needle, index + needle.length); 34 | } 35 | return index; 36 | }; 37 | 38 | const parseSmallUint = (str: string): number | undefined => (/^\d{1,4}$/.test(str) ? parseInt(str, 10) : undefined); 39 | 40 | const charToPiece = (ch: string): Piece | undefined => { 41 | const role = charToRole(ch); 42 | return role && { role, color: ch.toLowerCase() === ch ? 'black' : 'white' }; 43 | }; 44 | 45 | export const parseBoardFen = (boardPart: string): Result => { 46 | const board = Board.empty(); 47 | let rank = 7; 48 | let file = 0; 49 | for (let i = 0; i < boardPart.length; i++) { 50 | const c = boardPart[i]; 51 | if (c === '/' && file === 8) { 52 | file = 0; 53 | rank--; 54 | } else { 55 | const step = parseInt(c, 10); 56 | if (step > 0) file += step; 57 | else { 58 | if (file >= 8 || rank < 0) return Result.err(new FenError(InvalidFen.Board)); 59 | const square = file + rank * 8; 60 | const piece = charToPiece(c); 61 | if (!piece) return Result.err(new FenError(InvalidFen.Board)); 62 | if (boardPart[i + 1] === '~') { 63 | piece.promoted = true; 64 | i++; 65 | } 66 | board.set(square, piece); 67 | file++; 68 | } 69 | } 70 | } 71 | if (rank !== 0 || file !== 8) return Result.err(new FenError(InvalidFen.Board)); 72 | return Result.ok(board); 73 | }; 74 | 75 | export const parsePockets = (pocketPart: string): Result => { 76 | if (pocketPart.length > 64) return Result.err(new FenError(InvalidFen.Pockets)); 77 | const pockets = Material.empty(); 78 | for (const c of pocketPart) { 79 | const piece = charToPiece(c); 80 | if (!piece) return Result.err(new FenError(InvalidFen.Pockets)); 81 | pockets[piece.color][piece.role]++; 82 | } 83 | return Result.ok(pockets); 84 | }; 85 | 86 | export const parseCastlingFen = (board: Board, castlingPart: string): Result => { 87 | let castlingRights = SquareSet.empty(); 88 | if (castlingPart === '-') return Result.ok(castlingRights); 89 | 90 | for (const c of castlingPart) { 91 | const lower = c.toLowerCase(); 92 | const color = c === lower ? 'black' : 'white'; 93 | const rank = color === 'white' ? 0 : 7; 94 | if ('a' <= lower && lower <= 'h') { 95 | castlingRights = castlingRights.with(squareFromCoords(lower.charCodeAt(0) - 'a'.charCodeAt(0), rank)!); 96 | } else if (lower === 'k' || lower === 'q') { 97 | const rooksAndKings = board[color].intersect(SquareSet.backrank(color)).intersect(board.rook.union(board.king)); 98 | const candidate = lower === 'k' ? rooksAndKings.last() : rooksAndKings.first(); 99 | castlingRights = castlingRights.with( 100 | defined(candidate) && board.rook.has(candidate) ? candidate : squareFromCoords(lower === 'k' ? 7 : 0, rank)!, 101 | ); 102 | } else return Result.err(new FenError(InvalidFen.Castling)); 103 | } 104 | 105 | if (COLORS.some(color => SquareSet.backrank(color).intersect(castlingRights).size() > 2)) { 106 | return Result.err(new FenError(InvalidFen.Castling)); 107 | } 108 | 109 | return Result.ok(castlingRights); 110 | }; 111 | 112 | export const parseRemainingChecks = (part: string): Result => { 113 | const parts = part.split('+'); 114 | if (parts.length === 3 && parts[0] === '') { 115 | const white = parseSmallUint(parts[1]); 116 | const black = parseSmallUint(parts[2]); 117 | if (!defined(white) || white > 3 || !defined(black) || black > 3) { 118 | return Result.err(new FenError(InvalidFen.RemainingChecks)); 119 | } 120 | return Result.ok(new RemainingChecks(3 - white, 3 - black)); 121 | } else if (parts.length === 2) { 122 | const white = parseSmallUint(parts[0]); 123 | const black = parseSmallUint(parts[1]); 124 | if (!defined(white) || white > 3 || !defined(black) || black > 3) { 125 | return Result.err(new FenError(InvalidFen.RemainingChecks)); 126 | } 127 | return Result.ok(new RemainingChecks(white, black)); 128 | } else return Result.err(new FenError(InvalidFen.RemainingChecks)); 129 | }; 130 | 131 | export const parseFen = (fen: string): Result => { 132 | const parts = fen.split(/[\s_]+/); 133 | const boardPart = parts.shift()!; 134 | 135 | // Board and pockets 136 | let board: Result; 137 | let pockets = Result.ok(undefined); 138 | if (boardPart.endsWith(']')) { 139 | const pocketStart = boardPart.indexOf('['); 140 | if (pocketStart === -1) return Result.err(new FenError(InvalidFen.Fen)); 141 | board = parseBoardFen(boardPart.slice(0, pocketStart)); 142 | pockets = parsePockets(boardPart.slice(pocketStart + 1, -1)); 143 | } else { 144 | const pocketStart = nthIndexOf(boardPart, '/', 7); 145 | if (pocketStart === -1) board = parseBoardFen(boardPart); 146 | else { 147 | board = parseBoardFen(boardPart.slice(0, pocketStart)); 148 | pockets = parsePockets(boardPart.slice(pocketStart + 1)); 149 | } 150 | } 151 | 152 | // Turn 153 | let turn: Color; 154 | const turnPart = parts.shift(); 155 | if (!defined(turnPart) || turnPart === 'w') turn = 'white'; 156 | else if (turnPart === 'b') turn = 'black'; 157 | else return Result.err(new FenError(InvalidFen.Turn)); 158 | 159 | return board.chain(board => { 160 | // Castling 161 | const castlingPart = parts.shift(); 162 | const castlingRights = defined(castlingPart) ? parseCastlingFen(board, castlingPart) : Result.ok(SquareSet.empty()); 163 | 164 | // En passant square 165 | const epPart = parts.shift(); 166 | let epSquare: Square | undefined; 167 | if (defined(epPart) && epPart !== '-') { 168 | epSquare = parseSquare(epPart); 169 | if (!defined(epSquare)) return Result.err(new FenError(InvalidFen.EpSquare)); 170 | } 171 | 172 | // Halfmoves or remaining checks 173 | let halfmovePart = parts.shift(); 174 | let earlyRemainingChecks: Result | undefined; 175 | if (defined(halfmovePart) && halfmovePart.includes('+')) { 176 | earlyRemainingChecks = parseRemainingChecks(halfmovePart); 177 | halfmovePart = parts.shift(); 178 | } 179 | const halfmoves = defined(halfmovePart) ? parseSmallUint(halfmovePart) : 0; 180 | if (!defined(halfmoves)) return Result.err(new FenError(InvalidFen.Halfmoves)); 181 | 182 | const fullmovesPart = parts.shift(); 183 | const fullmoves = defined(fullmovesPart) ? parseSmallUint(fullmovesPart) : 1; 184 | if (!defined(fullmoves)) return Result.err(new FenError(InvalidFen.Fullmoves)); 185 | 186 | const remainingChecksPart = parts.shift(); 187 | let remainingChecks: Result = Result.ok(undefined); 188 | if (defined(remainingChecksPart)) { 189 | if (defined(earlyRemainingChecks)) return Result.err(new FenError(InvalidFen.RemainingChecks)); 190 | remainingChecks = parseRemainingChecks(remainingChecksPart); 191 | } else if (defined(earlyRemainingChecks)) { 192 | remainingChecks = earlyRemainingChecks; 193 | } 194 | 195 | if (parts.length > 0) return Result.err(new FenError(InvalidFen.Fen)); 196 | 197 | return pockets.chain(pockets => 198 | castlingRights.chain(castlingRights => 199 | remainingChecks.map(remainingChecks => { 200 | return { 201 | board, 202 | pockets, 203 | turn, 204 | castlingRights, 205 | remainingChecks, 206 | epSquare, 207 | halfmoves, 208 | fullmoves: Math.max(1, fullmoves), 209 | }; 210 | }) 211 | ) 212 | ); 213 | }); 214 | }; 215 | 216 | export interface FenOpts { 217 | epd?: boolean; 218 | } 219 | 220 | export const parsePiece = (str: string): Piece | undefined => { 221 | if (!str) return; 222 | const piece = charToPiece(str[0]); 223 | if (!piece) return; 224 | if (str.length === 2 && str[1] === '~') piece.promoted = true; 225 | else if (str.length > 1) return; 226 | return piece; 227 | }; 228 | 229 | export const makePiece = (piece: Piece): string => { 230 | let r: string = roleToChar(piece.role); 231 | if (piece.color === 'white') r = r.toUpperCase(); 232 | if (piece.promoted) r += '~'; 233 | return r; 234 | }; 235 | 236 | export const makeBoardFen = (board: Board): string => { 237 | let fen = ''; 238 | let empty = 0; 239 | for (let rank = 7; rank >= 0; rank--) { 240 | for (let file = 0; file < 8; file++) { 241 | const square = file + rank * 8; 242 | const piece = board.get(square); 243 | if (!piece) empty++; 244 | else { 245 | if (empty > 0) { 246 | fen += empty; 247 | empty = 0; 248 | } 249 | fen += makePiece(piece); 250 | } 251 | 252 | if (file === 7) { 253 | if (empty > 0) { 254 | fen += empty; 255 | empty = 0; 256 | } 257 | if (rank !== 0) fen += '/'; 258 | } 259 | } 260 | } 261 | return fen; 262 | }; 263 | 264 | export const makePocket = (material: MaterialSide): string => 265 | ROLES.map(role => roleToChar(role).repeat(material[role])).join(''); 266 | 267 | export const makePockets = (pocket: Material): string => 268 | makePocket(pocket.white).toUpperCase() + makePocket(pocket.black); 269 | 270 | export const makeCastlingFen = (board: Board, castlingRights: SquareSet): string => { 271 | let fen = ''; 272 | for (const color of COLORS) { 273 | const backrank = SquareSet.backrank(color); 274 | let king = board.kingOf(color); 275 | if (defined(king) && !backrank.has(king)) king = undefined; 276 | const candidates = board.pieces(color, 'rook').intersect(backrank); 277 | for (const rook of castlingRights.intersect(backrank).reversed()) { 278 | if (rook === candidates.first() && defined(king) && rook < king) { 279 | fen += color === 'white' ? 'Q' : 'q'; 280 | } else if (rook === candidates.last() && defined(king) && king < rook) { 281 | fen += color === 'white' ? 'K' : 'k'; 282 | } else { 283 | const file = FILE_NAMES[squareFile(rook)]; 284 | fen += color === 'white' ? file.toUpperCase() : file; 285 | } 286 | } 287 | } 288 | return fen || '-'; 289 | }; 290 | 291 | export const makeRemainingChecks = (checks: RemainingChecks): string => `${checks.white}+${checks.black}`; 292 | 293 | export const makeFen = (setup: Setup, opts?: FenOpts): string => 294 | [ 295 | makeBoardFen(setup.board) + (setup.pockets ? `[${makePockets(setup.pockets)}]` : ''), 296 | setup.turn[0], 297 | makeCastlingFen(setup.board, setup.castlingRights), 298 | defined(setup.epSquare) ? makeSquare(setup.epSquare) : '-', 299 | ...(setup.remainingChecks ? [makeRemainingChecks(setup.remainingChecks)] : []), 300 | ...(opts?.epd ? [] : [Math.max(0, Math.min(setup.halfmoves, 9999)), Math.max(1, Math.min(setup.fullmoves, 9999))]), 301 | ].join(' '); 302 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | ByCastlingSide, 3 | ByColor, 4 | ByRole, 5 | BySquare, 6 | CASTLING_SIDES, 7 | CastlingSide, 8 | Color, 9 | COLORS, 10 | DropMove, 11 | FILE_NAMES, 12 | FileName, 13 | isDrop, 14 | isNormal, 15 | Move, 16 | NormalMove, 17 | Outcome, 18 | Piece, 19 | RANK_NAMES, 20 | RankName, 21 | Role, 22 | ROLES, 23 | RULES, 24 | Rules, 25 | Square, 26 | SquareName, 27 | } from './types.js'; 28 | 29 | export { 30 | charToRole, 31 | defined, 32 | kingCastlesTo, 33 | makeSquare, 34 | makeUci, 35 | opposite, 36 | parseSquare, 37 | parseUci, 38 | roleToChar, 39 | squareFile, 40 | squareRank, 41 | } from './util.js'; 42 | 43 | export { SquareSet } from './squareSet.js'; 44 | 45 | export { 46 | attacks, 47 | between, 48 | bishopAttacks, 49 | kingAttacks, 50 | knightAttacks, 51 | pawnAttacks, 52 | queenAttacks, 53 | ray, 54 | rookAttacks, 55 | } from './attacks.js'; 56 | 57 | export { Board } from './board.js'; 58 | 59 | export { defaultSetup, Material, MaterialSide, RemainingChecks, Setup } from './setup.js'; 60 | 61 | export { Castles, Chess, Context, IllegalSetup, Position, PositionError } from './chess.js'; 62 | 63 | export * as compat from './compat.js'; 64 | 65 | export * as debug from './debug.js'; 66 | 67 | export * as fen from './fen.js'; 68 | 69 | export * as san from './san.js'; 70 | 71 | export * as transform from './transform.js'; 72 | 73 | export * as variant from './variant.js'; 74 | 75 | export * as pgn from './pgn.js'; 76 | -------------------------------------------------------------------------------- /src/pgn.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, jest, test } from '@jest/globals'; 2 | import { createReadStream } from 'fs'; 3 | import { Position } from './chess.js'; 4 | import { makeFen } from './fen.js'; 5 | import { 6 | ChildNode, 7 | defaultGame, 8 | emptyHeaders, 9 | extend, 10 | Game, 11 | isChildNode, 12 | makeComment, 13 | makePgn, 14 | Node, 15 | parseComment, 16 | parsePgn, 17 | PgnError, 18 | PgnNodeData, 19 | PgnParser, 20 | startingPosition, 21 | transform, 22 | } from './pgn.js'; 23 | import { parseSan } from './san.js'; 24 | 25 | interface GameCallback { 26 | (game: Game, err: PgnError | undefined): Error | void; 27 | } 28 | 29 | function testPgnFile({ fileName = '', numberOfGames = 1, allValid = true } = {}, ...callbacks: GameCallback[]) { 30 | test(`pgn file - ${fileName}`, done => { 31 | const stream = createReadStream(`./data/${fileName}.pgn`, { encoding: 'utf-8' }); 32 | const gameCallback = jest.fn((game: Game, err: PgnError | undefined) => { 33 | if (err) stream.destroy(err); 34 | if (allValid) expect(err).toBe(undefined); 35 | callbacks.forEach(callback => { 36 | expect(callback(game, err)).toBe(undefined); 37 | }); 38 | }); 39 | const parser = new PgnParser(gameCallback, emptyHeaders); 40 | stream 41 | .on('data', (chunk) => parser.parse(chunk as string, { stream: true })) 42 | .on('close', () => { 43 | parser.parse(''); 44 | expect(gameCallback).toHaveBeenCalledTimes(numberOfGames); 45 | done!(); 46 | }); 47 | }); 48 | } 49 | 50 | test('make pgn', () => { 51 | const root = new Node(); 52 | expect(isChildNode(root)).toBe(false); 53 | 54 | const e4 = new ChildNode({ 55 | san: 'e4', 56 | nags: [7], 57 | }); 58 | expect(isChildNode(e4)).toBe(true); 59 | const e3 = new ChildNode({ san: 'e3' }); 60 | root.children.push(e4); 61 | root.children.push(e3); 62 | 63 | const e5 = new ChildNode({ 64 | san: 'e5', 65 | }); 66 | const e6 = new ChildNode({ san: 'e6' }); 67 | e4.children.push(e5); 68 | e4.children.push(e6); 69 | 70 | const nf3 = new ChildNode({ 71 | san: 'Nf3', 72 | comments: ['a comment'], 73 | }); 74 | e6.children.push(nf3); 75 | 76 | const c4 = new ChildNode({ san: 'c4' }); 77 | e5.children.push(c4); 78 | 79 | expect(makePgn({ headers: emptyHeaders(), moves: root })).toEqual( 80 | '1. e4 $7 ( 1. e3 ) 1... e5 ( 1... e6 2. Nf3 { a comment } ) 2. c4 *\n', 81 | ); 82 | }); 83 | 84 | test('extend mainline', () => { 85 | const game: Game = defaultGame(emptyHeaders); 86 | extend(game.moves.end(), 'e4 d5 a3 h6 Bg5'.split(' ').map(san => ({ san }))); 87 | expect(makePgn(game)).toEqual('1. e4 d5 2. a3 h6 3. Bg5 *\n'); 88 | }); 89 | 90 | test('parse headers', () => { 91 | const games = parsePgn( 92 | [ 93 | '[Black "black player"]', 94 | '[White " white player "]', 95 | '[Escaped "quote: \\", backslashes: \\\\\\\\, trailing text"]', 96 | '[Multiple "on"] [the "same line"]', 97 | '[Incomplete', 98 | ].join('\r\n'), 99 | ); 100 | expect(games).toHaveLength(1); 101 | expect(games[0].headers.get('Black')).toBe('black player'); 102 | expect(games[0].headers.get('White')).toBe(' white player '); 103 | expect(games[0].headers.get('Escaped')).toBe('quote: ", backslashes: \\\\, trailing text'); 104 | expect(games[0].headers.get('Multiple')).toBe('on'); 105 | expect(games[0].headers.get('the')).toBe('same line'); 106 | expect(games[0].headers.get('Result')).toBe('*'); 107 | expect(games[0].headers.get('Event')).toBe('?'); 108 | }); 109 | 110 | test('parse pgn', () => { 111 | const callback = jest.fn((game: Game) => { 112 | expect(makePgn(game)).toBe('[Result "1-0"]\n\n1. e4 e5 2. Nf3 { foo\n bar baz } 1-0\n'); 113 | }); 114 | const parser = new PgnParser(callback, emptyHeaders); 115 | parser.parse('1. e4 \ne5', { stream: true }); 116 | parser.parse('\nNf3 {foo\n', { stream: true }); 117 | parser.parse(' bar baz } 1-', { stream: true }); 118 | parser.parse('', { stream: true }); 119 | parser.parse('0', { stream: true }); 120 | parser.parse(''); 121 | expect(callback).toHaveBeenCalledTimes(1); 122 | }); 123 | 124 | test('transform pgn', () => { 125 | interface TransformResult extends PgnNodeData { 126 | fen: string; 127 | } 128 | 129 | const game = parsePgn('1. a4 ( 1. b4 b5 -- ) 1... a5')[0]; 130 | const res = transform( 131 | game.moves, 132 | startingPosition(game.headers).unwrap(), 133 | (pos, data) => { 134 | const move = parseSan(pos, data.san); 135 | if (!move) return; 136 | pos.play(move); 137 | return { 138 | fen: makeFen(pos.toSetup()), 139 | ...data, 140 | }; 141 | }, 142 | ); 143 | expect(res.children[0].data.fen).toBe('rnbqkbnr/pppppppp/8/8/P7/8/1PPPPPPP/RNBQKBNR b KQkq - 0 1'); 144 | expect(res.children[0].children[0].data.fen).toBe('rnbqkbnr/1ppppppp/8/p7/P7/8/1PPPPPPP/RNBQKBNR w KQkq - 0 2'); 145 | expect(res.children[1].data.fen).toBe('rnbqkbnr/pppppppp/8/8/1P6/8/P1PPPPPP/RNBQKBNR b KQkq - 0 1'); 146 | expect(res.children[1].children[0].data.fen).toBe('rnbqkbnr/p1pppppp/8/1p6/1P6/8/P1PPPPPP/RNBQKBNR w KQkq - 0 2'); 147 | }); 148 | 149 | testPgnFile({ 150 | fileName: 'kasparov-deep-blue-1997', 151 | numberOfGames: 6, 152 | allValid: true, 153 | }); 154 | testPgnFile( 155 | { 156 | fileName: 'headers-and-moves-on-the-same-line', 157 | numberOfGames: 3, 158 | allValid: true, 159 | }, 160 | game => { 161 | expect(game.headers.get('Variant')).toBe('Antichess'); 162 | expect(Array.from(game.moves.mainline()).map(move => move.san)).toStrictEqual(['e3', 'e6', 'b4', 'Bxb4', 'Qg4']); 163 | }, 164 | ); 165 | testPgnFile( 166 | { 167 | fileName: 'pathological-headers', 168 | numberOfGames: 1, 169 | allValid: true, 170 | }, 171 | game => { 172 | expect(game.headers.get('A')).toBe('b"'); 173 | expect(game.headers.get('B')).toBe('b"'); 174 | expect(game.headers.get('C')).toBe('A]]'); 175 | expect(game.headers.get('D')).toBe('A]]['); 176 | expect(game.headers.get('E')).toBe('"A]]["'); 177 | expect(game.headers.get('F')).toBe('"A]]["\\'); 178 | expect(game.headers.get('G')).toBe('"]'); 179 | }, 180 | ); 181 | 182 | testPgnFile( 183 | { 184 | fileName: 'leading-whitespace', 185 | numberOfGames: 4, 186 | allValid: true, 187 | }, 188 | game => { 189 | expect(Array.from(game.moves.mainline()).map(move => move.san)).toStrictEqual(['e4', 'e5', 'Nf3', 'Nc6', 'Bb5']); 190 | }, 191 | ); 192 | 193 | test('tricky tokens', () => { 194 | const steps = Array.from(parsePgn('O-O-O !! 0-0-0# ??')[0].moves.mainline()); 195 | expect(steps[0].san).toBe('O-O-O'); 196 | expect(steps[0].nags).toEqual([3]); 197 | expect(steps[1].san).toBe('O-O-O#'); 198 | expect(steps[1].nags).toEqual([4]); 199 | }); 200 | 201 | test('en/em dash', () => { 202 | const game = parsePgn('14...0–0–0 15. O—O 1—0')[0]; 203 | const steps = Array.from(game.moves.mainline()); 204 | expect(game.headers.get('Result')).toBe('1-0'); 205 | expect(steps[0].san).toBe('O-O-O'); 206 | expect(steps[1].san).toBe('O-O'); 207 | }); 208 | 209 | test('parse comment', () => { 210 | expect(parseComment('prefix [%emt 1:02:03.4]')).toEqual({ 211 | text: 'prefix', 212 | emt: 3723.4, 213 | shapes: [], 214 | }); 215 | expect(parseComment('[%csl Ya1][%cal Ra1a1,Be1e2]commentary [%csl Gh8]')).toEqual({ 216 | text: 'commentary', 217 | shapes: [ 218 | { color: 'yellow', from: 0, to: 0 }, 219 | { color: 'red', from: 0, to: 0 }, 220 | { color: 'blue', from: 4, to: 12 }, 221 | { color: 'green', from: 63, to: 63 }, 222 | ], 223 | }); 224 | expect(parseComment('[%eval -0.42] suffix')).toEqual({ 225 | text: 'suffix', 226 | evaluation: { pawns: -0.42 }, 227 | shapes: [], 228 | }); 229 | expect(parseComment('prefix [%eval .99,23]')).toEqual({ 230 | text: 'prefix', 231 | evaluation: { pawns: 0.99, depth: 23 }, 232 | shapes: [], 233 | }); 234 | expect(parseComment('[%eval #-3] suffix')).toEqual({ 235 | text: 'suffix', 236 | evaluation: { mate: -3 }, 237 | shapes: [], 238 | }); 239 | expect(parseComment('[%csl Ga1]foo')).toEqual({ 240 | text: 'foo', 241 | shapes: [{ from: 0, to: 0, color: 'green' }], 242 | }); 243 | expect(parseComment('foo [%bar] [%csl Ga1] [%cal Ra1h1,Gb1b8] [%clk 3:25:45]').text).toBe('foo [%bar]'); 244 | }); 245 | 246 | test('make comment', () => { 247 | expect( 248 | makeComment({ 249 | text: 'text', 250 | emt: 3723.4, 251 | evaluation: { pawns: 10 }, 252 | clock: 1, 253 | shapes: [ 254 | { color: 'yellow', from: 0, to: 0 }, 255 | { color: 'red', from: 0, to: 1 }, 256 | { color: 'red', from: 0, to: 2 }, 257 | ], 258 | }), 259 | ).toBe('text [%csl Ya1] [%cal Ra1b1,Ra1c1] [%eval 10.00] [%emt 1:02:03.4] [%clk 0:00:01]'); 260 | 261 | expect( 262 | makeComment({ 263 | evaluation: { mate: -4, depth: 5 }, 264 | }), 265 | ).toBe('[%eval #-4,5]'); 266 | }); 267 | 268 | test.each([ 269 | '[%csl[%eval 0.2] Ga1]', 270 | '[%c[%csl [%csl Ga1[%csl Ga1][%[%csl Ga1][%cal[%csl Ga1]Ra1]', 271 | '[%csl Ga1][%cal Ra1h1,Gb1b8] foo [%clk 3:ê5: [%eval 450752] [%evaÿTæ<92>ÿÿ^?,7]', 272 | ])('roundtrip comment', str => { 273 | const comment = parseComment(str); 274 | const rountripped = parseComment(makeComment(comment)); 275 | expect(comment).toEqual(rountripped); 276 | }); 277 | -------------------------------------------------------------------------------- /src/pgn.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Parse, transform and write PGN. 3 | * 4 | * ## Parser 5 | * 6 | * The parser will interpret any input as a PGN, creating a tree of 7 | * syntactically valid (but not necessarily legal) moves, skipping any invalid 8 | * tokens. 9 | * 10 | * ```ts 11 | * import { parsePgn, startingPosition } from 'chessops/pgn'; 12 | * import { parseSan } from 'chessops/san'; 13 | * 14 | * const pgn = '1. d4 d5 *'; 15 | * const games = parsePgn(pgn); 16 | * for (const game of games) { 17 | * const pos = startingPosition(game.headers).unwrap(); 18 | * for (const node of game.moves.mainline()) { 19 | * const move = parseSan(pos, node.san); 20 | * if (!move) break; // Illegal move 21 | * pos.play(move); 22 | * } 23 | * } 24 | * ``` 25 | * 26 | * ## Streaming parser 27 | * 28 | * The module also provides a denial-of-service resistant streaming parser. 29 | * It can be configured with a budget for reasonable complexity of a single 30 | * game, fed with chunks of text, and will yield parsed games as they are 31 | * completed. 32 | * 33 | * ```ts 34 | * 35 | * import { createReadStream } from 'fs'; 36 | * import { PgnParser } from 'chessops/pgn'; 37 | * 38 | * const stream = createReadStream('games.pgn', { encoding: 'utf-8' }); 39 | * 40 | * const parser = new PgnParser((game, err) => { 41 | * if (err) { 42 | * // Budget exceeded. 43 | * stream.destroy(err); 44 | * } 45 | * 46 | * // Use game ... 47 | * }); 48 | * 49 | * await new Promise(resolve => 50 | * stream 51 | * .on('data', (chunk: string) => parser.parse(chunk, { stream: true })) 52 | * .on('close', () => { 53 | * parser.parse(''); 54 | * resolve(); 55 | * }) 56 | * ); 57 | * ``` 58 | * 59 | * ## Augmenting the game tree 60 | * 61 | * You can use `walk` to visit all nodes in the game tree, or `transform` 62 | * to augment it with user data. 63 | * 64 | * Both allow you to provide context. You update the context inside the 65 | * callback, and it is automatically `clone()`-ed at each fork. 66 | * In the example below, the current position `pos` is provided as context. 67 | * 68 | * ```ts 69 | * import { transform } from 'chessops/pgn'; 70 | * import { makeFen } from 'chessops/fen'; 71 | * import { parseSan, makeSanAndPlay } from 'chessops/san'; 72 | * 73 | * const pos = startingPosition(game.headers).unwrap(); 74 | * game.moves = transform(game.moves, pos, (pos, node) => { 75 | * const move = parseSan(pos, node.san); 76 | * if (!move) { 77 | * // Illegal move. Returning undefined cuts off the tree here. 78 | * return; 79 | * } 80 | * 81 | * const san = makeSanAndPlay(pos, move); // Mutating pos! 82 | * 83 | * return { 84 | * ...node, // Keep comments and annotation glyphs 85 | * san, // Normalized SAN 86 | * fen: makeFen(pos.toSetup()), // Add arbitrary user data to node 87 | * }; 88 | * }); 89 | * ``` 90 | * 91 | * ## Writing 92 | * 93 | * Requires each node to at least have a `san` property. 94 | * 95 | * ``` 96 | * import { makePgn } from 'chessops/pgn'; 97 | * 98 | * const rewrittenPgn = makePgn(game); 99 | * ``` 100 | * 101 | * @packageDocumentation 102 | */ 103 | import { Result } from '@badrap/result'; 104 | import { IllegalSetup, Position, PositionError } from './chess.js'; 105 | import { FenError, makeFen, parseFen } from './fen.js'; 106 | import { Outcome, Rules, Square } from './types.js'; 107 | import { defined, makeSquare, parseSquare } from './util.js'; 108 | import { defaultPosition, setupPosition } from './variant.js'; 109 | 110 | export interface Game { 111 | headers: Map; 112 | comments?: string[]; 113 | moves: Node; 114 | } 115 | 116 | export const defaultGame = (initHeaders: () => Map = defaultHeaders): Game => ({ 117 | headers: initHeaders(), 118 | moves: new Node(), 119 | }); 120 | 121 | export class Node { 122 | children: ChildNode[] = []; 123 | 124 | *mainlineNodes(): Iterable> { 125 | let node: Node = this; 126 | while (node.children.length) { 127 | const child = node.children[0]; 128 | yield child; 129 | node = child; 130 | } 131 | } 132 | 133 | *mainline(): Iterable { 134 | for (const child of this.mainlineNodes()) yield child.data; 135 | } 136 | 137 | end(): Node { 138 | let node: Node = this; 139 | while (node.children.length) node = node.children[0]; 140 | return node; 141 | } 142 | } 143 | 144 | export class ChildNode extends Node { 145 | constructor(public data: T) { 146 | super(); 147 | } 148 | } 149 | 150 | export const isChildNode = (node: Node): node is ChildNode => node instanceof ChildNode; 151 | 152 | export const extend = (node: Node, data: T[]): Node => { 153 | for (const d of data) { 154 | const child = new ChildNode(d); 155 | node.children.push(child); 156 | node = child; 157 | } 158 | return node; 159 | }; 160 | 161 | export class Box { 162 | constructor(public value: T) {} 163 | 164 | clone(): Box { 165 | return new Box(this.value); 166 | } 167 | } 168 | 169 | export const transform = ( 170 | node: Node, 171 | ctx: C, 172 | f: (ctx: C, data: T, childIndex: number) => U | undefined, 173 | ): Node => { 174 | const root = new Node(); 175 | const stack = [ 176 | { 177 | before: node, 178 | after: root, 179 | ctx, 180 | }, 181 | ]; 182 | let frame; 183 | while ((frame = stack.pop())) { 184 | for (let childIndex = 0; childIndex < frame.before.children.length; childIndex++) { 185 | const ctx = childIndex < frame.before.children.length - 1 ? frame.ctx.clone() : frame.ctx; 186 | const childBefore = frame.before.children[childIndex]; 187 | const data = f(ctx, childBefore.data, childIndex); 188 | if (defined(data)) { 189 | const childAfter = new ChildNode(data); 190 | frame.after.children.push(childAfter); 191 | stack.push({ 192 | before: childBefore, 193 | after: childAfter, 194 | ctx, 195 | }); 196 | } 197 | } 198 | } 199 | return root; 200 | }; 201 | 202 | export const walk = ( 203 | node: Node, 204 | ctx: C, 205 | f: (ctx: C, data: T, childIndex: number) => boolean | void, 206 | ) => { 207 | const stack = [{ node, ctx }]; 208 | let frame; 209 | while ((frame = stack.pop())) { 210 | for (let childIndex = 0; childIndex < frame.node.children.length; childIndex++) { 211 | const ctx = childIndex < frame.node.children.length - 1 ? frame.ctx.clone() : frame.ctx; 212 | const child = frame.node.children[childIndex]; 213 | if (f(ctx, child.data, childIndex) !== false) stack.push({ node: child, ctx }); 214 | } 215 | } 216 | }; 217 | 218 | export interface PgnNodeData { 219 | san: string; 220 | startingComments?: string[]; 221 | comments?: string[]; 222 | nags?: number[]; 223 | } 224 | 225 | export const makeOutcome = (outcome: Outcome | undefined): string => { 226 | if (!outcome) return '*'; 227 | else if (outcome.winner === 'white') return '1-0'; 228 | else if (outcome.winner === 'black') return '0-1'; 229 | else return '1/2-1/2'; 230 | }; 231 | 232 | export const parseOutcome = (s: string | undefined): Outcome | undefined => { 233 | if (s === '1-0' || s === '1–0' || s === '1—0') return { winner: 'white' }; 234 | else if (s === '0-1' || s === '0–1' || s === '0—1') return { winner: 'black' }; 235 | else if (s === '1/2-1/2' || s === '1/2–1/2' || s === '1/2—1/2') return { winner: undefined }; 236 | else return; 237 | }; 238 | 239 | const escapeHeader = (value: string): string => value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); 240 | 241 | const safeComment = (comment: string): string => comment.replace(/\}/g, ''); 242 | 243 | const enum MakePgnState { 244 | Pre = 0, 245 | Sidelines = 1, 246 | End = 2, 247 | } 248 | 249 | interface MakePgnFrame { 250 | state: MakePgnState; 251 | ply: number; 252 | node: ChildNode; 253 | sidelines: Iterator>; 254 | startsVariation: boolean; 255 | inVariation: boolean; 256 | } 257 | 258 | export const makePgn = (game: Game): string => { 259 | const builder = [], 260 | tokens = []; 261 | 262 | if (game.headers.size) { 263 | for (const [key, value] of game.headers.entries()) { 264 | builder.push('[', key, ' "', escapeHeader(value), '"]\n'); 265 | } 266 | builder.push('\n'); 267 | } 268 | 269 | for (const comment of game.comments || []) tokens.push('{', safeComment(comment), '}'); 270 | 271 | const fen = game.headers.get('FEN'); 272 | const initialPly = fen 273 | ? parseFen(fen).unwrap( 274 | setup => (setup.fullmoves - 1) * 2 + (setup.turn === 'white' ? 0 : 1), 275 | _ => 0, 276 | ) 277 | : 0; 278 | 279 | const stack: MakePgnFrame[] = []; 280 | 281 | const variations = game.moves.children[Symbol.iterator](); 282 | const firstVariation = variations.next(); 283 | if (!firstVariation.done) { 284 | stack.push({ 285 | state: MakePgnState.Pre, 286 | ply: initialPly, 287 | node: firstVariation.value, 288 | sidelines: variations, 289 | startsVariation: false, 290 | inVariation: false, 291 | }); 292 | } 293 | 294 | let forceMoveNumber = true; 295 | while (stack.length) { 296 | const frame = stack[stack.length - 1]; 297 | 298 | if (frame.inVariation) { 299 | tokens.push(')'); 300 | frame.inVariation = false; 301 | forceMoveNumber = true; 302 | } 303 | 304 | switch (frame.state) { 305 | case MakePgnState.Pre: 306 | for (const comment of frame.node.data.startingComments || []) { 307 | tokens.push('{', safeComment(comment), '}'); 308 | forceMoveNumber = true; 309 | } 310 | if (forceMoveNumber || frame.ply % 2 === 0) { 311 | tokens.push(Math.floor(frame.ply / 2) + 1 + (frame.ply % 2 ? '...' : '.')); 312 | forceMoveNumber = false; 313 | } 314 | tokens.push(frame.node.data.san); 315 | for (const nag of frame.node.data.nags || []) { 316 | tokens.push('$' + nag); 317 | forceMoveNumber = true; 318 | } 319 | for (const comment of frame.node.data.comments || []) { 320 | tokens.push('{', safeComment(comment), '}'); 321 | } 322 | frame.state = MakePgnState.Sidelines; // fall through 323 | case MakePgnState.Sidelines: { 324 | const child = frame.sidelines.next(); 325 | if (child.done) { 326 | const variations = frame.node.children[Symbol.iterator](); 327 | const firstVariation = variations.next(); 328 | if (!firstVariation.done) { 329 | stack.push({ 330 | state: MakePgnState.Pre, 331 | ply: frame.ply + 1, 332 | node: firstVariation.value, 333 | sidelines: variations, 334 | startsVariation: false, 335 | inVariation: false, 336 | }); 337 | } 338 | frame.state = MakePgnState.End; 339 | } else { 340 | tokens.push('('); 341 | forceMoveNumber = true; 342 | stack.push({ 343 | state: MakePgnState.Pre, 344 | ply: frame.ply, 345 | node: child.value, 346 | sidelines: [][Symbol.iterator](), 347 | startsVariation: true, 348 | inVariation: false, 349 | }); 350 | frame.inVariation = true; 351 | } 352 | break; 353 | } 354 | case MakePgnState.End: 355 | stack.pop(); 356 | } 357 | } 358 | 359 | tokens.push(makeOutcome(parseOutcome(game.headers.get('Result')))); 360 | 361 | builder.push(tokens.join(' '), '\n'); 362 | return builder.join(''); 363 | }; 364 | 365 | export const defaultHeaders = (): Map => 366 | new Map([ 367 | ['Event', '?'], 368 | ['Site', '?'], 369 | ['Date', '????.??.??'], 370 | ['Round', '?'], 371 | ['White', '?'], 372 | ['Black', '?'], 373 | ['Result', '*'], 374 | ]); 375 | 376 | export const emptyHeaders = (): Map => new Map(); 377 | 378 | const BOM = '\ufeff'; 379 | 380 | const isWhitespace = (line: string): boolean => /^\s*$/.test(line); 381 | 382 | const isCommentLine = (line: string): boolean => line.startsWith('%'); 383 | 384 | export interface ParseOptions { 385 | stream: boolean; 386 | } 387 | 388 | interface ParserFrame { 389 | parent: Node; 390 | root: boolean; 391 | node?: ChildNode; 392 | startingComments?: string[]; 393 | } 394 | 395 | const enum ParserState { 396 | Bom = 0, 397 | Pre = 1, 398 | Headers = 2, 399 | Moves = 3, 400 | Comment = 4, 401 | } 402 | 403 | export class PgnError extends Error {} 404 | 405 | export class PgnParser { 406 | private lineBuf: string[] = []; 407 | 408 | private budget: number; 409 | private found: boolean; 410 | private state: ParserState; 411 | private game: Game; 412 | private stack: ParserFrame[]; 413 | private commentBuf: string[]; 414 | 415 | constructor( 416 | private emitGame: (game: Game, err: PgnError | undefined) => void, 417 | private initHeaders: () => Map = defaultHeaders, 418 | private maxBudget = 1_000_000, 419 | ) { 420 | this.resetGame(); 421 | this.state = ParserState.Bom; 422 | } 423 | 424 | private resetGame() { 425 | this.budget = this.maxBudget; 426 | this.found = false; 427 | this.state = ParserState.Pre; 428 | this.game = defaultGame(this.initHeaders); 429 | this.stack = [{ parent: this.game.moves, root: true }]; 430 | this.commentBuf = []; 431 | } 432 | 433 | private consumeBudget(cost: number) { 434 | this.budget -= cost; 435 | if (this.budget < 0) throw new PgnError('ERR_PGN_BUDGET'); 436 | } 437 | 438 | parse(data: string, options?: ParseOptions): void { 439 | if (this.budget < 0) return; 440 | try { 441 | let idx = 0; 442 | for (;;) { 443 | const nlIdx = data.indexOf('\n', idx); 444 | if (nlIdx === -1) { 445 | break; 446 | } 447 | const crIdx = nlIdx > idx && data[nlIdx - 1] === '\r' ? nlIdx - 1 : nlIdx; 448 | this.consumeBudget(nlIdx - idx); 449 | this.lineBuf.push(data.slice(idx, crIdx)); 450 | idx = nlIdx + 1; 451 | this.handleLine(); 452 | } 453 | this.consumeBudget(data.length - idx); 454 | this.lineBuf.push(data.slice(idx)); 455 | 456 | if (!options?.stream) { 457 | this.handleLine(); 458 | this.emit(undefined); 459 | } 460 | } catch (err: unknown) { 461 | this.emit(err as PgnError); 462 | } 463 | } 464 | 465 | private handleLine() { 466 | let freshLine = true; 467 | let line = this.lineBuf.join(''); 468 | this.lineBuf = []; 469 | 470 | continuedLine: for (;;) { 471 | switch (this.state) { 472 | case ParserState.Bom: 473 | if (line.startsWith(BOM)) line = line.slice(BOM.length); 474 | this.state = ParserState.Pre; // fall through 475 | case ParserState.Pre: 476 | if (isWhitespace(line) || isCommentLine(line)) return; 477 | this.found = true; 478 | this.state = ParserState.Headers; // fall through 479 | case ParserState.Headers: { 480 | if (isCommentLine(line)) return; 481 | let moreHeaders = true; 482 | while (moreHeaders) { 483 | moreHeaders = false; 484 | line = line.replace( 485 | /^\s*\[([A-Za-z0-9][A-Za-z0-9_+#=:-]*)\s+"((?:[^"\\]|\\"|\\\\)*)"\]/, 486 | (_match, headerName, headerValue) => { 487 | this.consumeBudget(200); 488 | this.handleHeader(headerName, headerValue.replace(/\\"/g, '"').replace(/\\\\/g, '\\')); 489 | moreHeaders = true; 490 | freshLine = false; 491 | return ''; 492 | }, 493 | ); 494 | } 495 | if (isWhitespace(line)) return; 496 | this.state = ParserState.Moves; // fall through 497 | } 498 | case ParserState.Moves: { 499 | if (freshLine) { 500 | if (isCommentLine(line)) return; 501 | if (isWhitespace(line)) return this.emit(undefined); 502 | } 503 | const tokenRegex = 504 | /(?:[NBKRQ]?[a-h]?[1-8]?[-x]?[a-h][1-8](?:=?[nbrqkNBRQK])?|[pnbrqkPNBRQK]?@[a-h][1-8]|[O0o][-–—][O0o](?:[-–—][O0o])?)[+#]?|--|Z0|0000|@@@@|{|;|\$\d{1,4}|[?!]{1,2}|\(|\)|\*|1[-–—]0|0[-–—]1|1\/2[-–—]1\/2/g; 505 | let match; 506 | while ((match = tokenRegex.exec(line))) { 507 | const frame = this.stack[this.stack.length - 1]; 508 | let token = match[0]; 509 | if (token === ';') return; 510 | else if (token.startsWith('$')) this.handleNag(parseInt(token.slice(1), 10)); 511 | else if (token === '!') this.handleNag(1); 512 | else if (token === '?') this.handleNag(2); 513 | else if (token === '!!') this.handleNag(3); 514 | else if (token === '??') this.handleNag(4); 515 | else if (token === '!?') this.handleNag(5); 516 | else if (token === '?!') this.handleNag(6); 517 | else if ( 518 | token === '1-0' || token === '1–0' || token === '1—0' 519 | || token === '0-1' || token === '0–1' || token === '0—1' 520 | || token === '1/2-1/2' || token === '1/2–1/2' || token === '1/2—1/2' 521 | || token === '*' 522 | ) { 523 | if (this.stack.length === 1 && token !== '*') this.handleHeader('Result', token); 524 | } else if (token === '(') { 525 | this.consumeBudget(100); 526 | this.stack.push({ parent: frame.parent, root: false }); 527 | } else if (token === ')') { 528 | if (this.stack.length > 1) this.stack.pop(); 529 | } else if (token === '{') { 530 | const openIndex = tokenRegex.lastIndex; 531 | const beginIndex = line[openIndex] === ' ' ? openIndex + 1 : openIndex; 532 | line = line.slice(beginIndex); 533 | this.state = ParserState.Comment; 534 | continue continuedLine; 535 | } else { 536 | this.consumeBudget(100); 537 | if (token.startsWith('O') || token.startsWith('0') || token.startsWith('o')) { 538 | token = token.replace(/[0o]/g, 'O').replace(/[–—]/g, '-'); 539 | } else if (token === 'Z0' || token === '0000' || token === '@@@@') token = '--'; 540 | 541 | if (frame.node) frame.parent = frame.node; 542 | frame.node = new ChildNode({ 543 | san: token, 544 | startingComments: frame.startingComments, 545 | }); 546 | frame.startingComments = undefined; 547 | frame.root = false; 548 | frame.parent.children.push(frame.node); 549 | } 550 | } 551 | return; 552 | } 553 | case ParserState.Comment: { 554 | const closeIndex = line.indexOf('}'); 555 | if (closeIndex === -1) { 556 | this.commentBuf.push(line); 557 | return; 558 | } else { 559 | const endIndex = closeIndex > 0 && line[closeIndex - 1] === ' ' ? closeIndex - 1 : closeIndex; 560 | this.commentBuf.push(line.slice(0, endIndex)); 561 | this.handleComment(); 562 | line = line.slice(closeIndex); 563 | this.state = ParserState.Moves; 564 | freshLine = false; 565 | } 566 | } 567 | } 568 | } 569 | } 570 | 571 | private handleHeader(name: string, value: string) { 572 | this.game.headers.set(name, name === 'Result' ? makeOutcome(parseOutcome(value)) : value); 573 | } 574 | 575 | private handleNag(nag: number) { 576 | this.consumeBudget(50); 577 | const frame = this.stack[this.stack.length - 1]; 578 | if (frame.node) { 579 | frame.node.data.nags ||= []; 580 | frame.node.data.nags.push(nag); 581 | } 582 | } 583 | 584 | private handleComment() { 585 | this.consumeBudget(100); 586 | const frame = this.stack[this.stack.length - 1]; 587 | const comment = this.commentBuf.join('\n'); 588 | this.commentBuf = []; 589 | if (frame.node) { 590 | frame.node.data.comments ||= []; 591 | frame.node.data.comments.push(comment); 592 | } else if (frame.root) { 593 | this.game.comments ||= []; 594 | this.game.comments.push(comment); 595 | } else { 596 | frame.startingComments ||= []; 597 | frame.startingComments.push(comment); 598 | } 599 | } 600 | 601 | private emit(err: PgnError | undefined) { 602 | if (this.state === ParserState.Comment) this.handleComment(); 603 | if (err) return this.emitGame(this.game, err); 604 | if (this.found) this.emitGame(this.game, undefined); 605 | this.resetGame(); 606 | } 607 | } 608 | 609 | export const parsePgn = (pgn: string, initHeaders: () => Map = defaultHeaders): Game[] => { 610 | const games: Game[] = []; 611 | new PgnParser(game => games.push(game), initHeaders, NaN).parse(pgn); 612 | return games; 613 | }; 614 | 615 | export const parseVariant = (variant: string | undefined): Rules | undefined => { 616 | switch ((variant || 'chess').toLowerCase()) { 617 | case 'chess': 618 | case 'chess960': 619 | case 'chess 960': 620 | case 'standard': 621 | case 'from position': 622 | case 'classical': 623 | case 'normal': 624 | case 'fischerandom': // Cute Chess 625 | case 'fischerrandom': 626 | case 'fischer random': 627 | case 'wild/0': 628 | case 'wild/1': 629 | case 'wild/2': 630 | case 'wild/3': 631 | case 'wild/4': 632 | case 'wild/5': 633 | case 'wild/6': 634 | case 'wild/7': 635 | case 'wild/8': 636 | case 'wild/8a': 637 | return 'chess'; 638 | case 'crazyhouse': 639 | case 'crazy house': 640 | case 'house': 641 | case 'zh': 642 | return 'crazyhouse'; 643 | case 'king of the hill': 644 | case 'koth': 645 | case 'kingofthehill': 646 | return 'kingofthehill'; 647 | case 'three-check': 648 | case 'three check': 649 | case 'threecheck': 650 | case 'three check chess': 651 | case '3-check': 652 | case '3 check': 653 | case '3check': 654 | return '3check'; 655 | case 'antichess': 656 | case 'anti chess': 657 | case 'anti': 658 | return 'antichess'; 659 | case 'atomic': 660 | case 'atom': 661 | case 'atomic chess': 662 | return 'atomic'; 663 | case 'horde': 664 | case 'horde chess': 665 | return 'horde'; 666 | case 'racing kings': 667 | case 'racingkings': 668 | case 'racing': 669 | case 'race': 670 | return 'racingkings'; 671 | default: 672 | return; 673 | } 674 | }; 675 | 676 | export const makeVariant = (rules: Rules): string | undefined => { 677 | switch (rules) { 678 | case 'chess': 679 | return; 680 | case 'crazyhouse': 681 | return 'Crazyhouse'; 682 | case 'racingkings': 683 | return 'Racing Kings'; 684 | case 'horde': 685 | return 'Horde'; 686 | case 'atomic': 687 | return 'Atomic'; 688 | case 'antichess': 689 | return 'Antichess'; 690 | case '3check': 691 | return 'Three-check'; 692 | case 'kingofthehill': 693 | return 'King of the Hill'; 694 | } 695 | }; 696 | 697 | export const startingPosition = (headers: Map): Result => { 698 | const rules = parseVariant(headers.get('Variant')); 699 | if (!rules) return Result.err(new PositionError(IllegalSetup.Variant)); 700 | const fen = headers.get('FEN'); 701 | if (fen) return parseFen(fen).chain(setup => setupPosition(rules, setup)); 702 | else return Result.ok(defaultPosition(rules)); 703 | }; 704 | 705 | export const setStartingPosition = (headers: Map, pos: Position) => { 706 | const variant = makeVariant(pos.rules); 707 | if (variant) headers.set('Variant', variant); 708 | else headers.delete('Variant'); 709 | 710 | const fen = makeFen(pos.toSetup()); 711 | const defaultFen = makeFen(defaultPosition(pos.rules).toSetup()); 712 | if (fen !== defaultFen) headers.set('FEN', fen); 713 | else headers.delete('FEN'); 714 | }; 715 | 716 | export type CommentShapeColor = 'green' | 'red' | 'yellow' | 'blue'; 717 | 718 | export interface CommentShape { 719 | color: CommentShapeColor; 720 | from: Square; 721 | to: Square; 722 | } 723 | 724 | export type EvaluationPawns = { pawns: number; depth?: number }; 725 | export type EvaluationMate = { mate: number; depth?: number }; 726 | export type Evaluation = EvaluationPawns | EvaluationMate; 727 | 728 | export const isPawns = (ev: Evaluation): ev is EvaluationPawns => 'pawns' in ev; 729 | export const isMate = (ev: Evaluation): ev is EvaluationMate => 'mate' in ev; 730 | 731 | export interface Comment { 732 | text: string; 733 | shapes: CommentShape[]; 734 | clock?: number; 735 | emt?: number; 736 | evaluation?: Evaluation; 737 | } 738 | 739 | const makeClk = (seconds: number): string => { 740 | seconds = Math.max(0, seconds); 741 | const hours = Math.floor(seconds / 3600); 742 | const minutes = Math.floor((seconds % 3600) / 60); 743 | seconds = (seconds % 3600) % 60; 744 | return `${hours}:${minutes.toString().padStart(2, '0')}:${ 745 | seconds.toLocaleString('en', { 746 | minimumIntegerDigits: 2, 747 | maximumFractionDigits: 3, 748 | }) 749 | }`; 750 | }; 751 | 752 | const makeCommentShapeColor = (color: CommentShapeColor): 'G' | 'R' | 'Y' | 'B' => { 753 | switch (color) { 754 | case 'green': 755 | return 'G'; 756 | case 'red': 757 | return 'R'; 758 | case 'yellow': 759 | return 'Y'; 760 | case 'blue': 761 | return 'B'; 762 | } 763 | }; 764 | 765 | function parseCommentShapeColor(str: 'G' | 'R' | 'Y' | 'B'): CommentShapeColor; 766 | function parseCommentShapeColor(str: string): CommentShapeColor | undefined; 767 | function parseCommentShapeColor(str: string): CommentShapeColor | undefined { 768 | switch (str) { 769 | case 'G': 770 | return 'green'; 771 | case 'R': 772 | return 'red'; 773 | case 'Y': 774 | return 'yellow'; 775 | case 'B': 776 | return 'blue'; 777 | default: 778 | return; 779 | } 780 | } 781 | 782 | const makeCommentShape = (shape: CommentShape): string => 783 | shape.to === shape.from 784 | ? `${makeCommentShapeColor(shape.color)}${makeSquare(shape.to)}` 785 | : `${makeCommentShapeColor(shape.color)}${makeSquare(shape.from)}${makeSquare(shape.to)}`; 786 | 787 | const parseCommentShape = (str: string): CommentShape | undefined => { 788 | const color = parseCommentShapeColor(str.slice(0, 1)); 789 | const from = parseSquare(str.slice(1, 3)); 790 | const to = parseSquare(str.slice(3, 5)); 791 | if (!color || !defined(from)) return; 792 | if (str.length === 3) return { color, from, to: from }; 793 | if (str.length === 5 && defined(to)) return { color, from, to }; 794 | return; 795 | }; 796 | 797 | const makeEval = (ev: Evaluation): string => { 798 | const str = isMate(ev) ? '#' + ev.mate : ev.pawns.toFixed(2); 799 | return defined(ev.depth) ? str + ',' + ev.depth : str; 800 | }; 801 | 802 | export const makeComment = (comment: Partial): string => { 803 | const builder = []; 804 | if (defined(comment.text)) builder.push(comment.text); 805 | const circles = (comment.shapes || []).filter(shape => shape.to === shape.from).map(makeCommentShape); 806 | if (circles.length) builder.push(`[%csl ${circles.join(',')}]`); 807 | const arrows = (comment.shapes || []).filter(shape => shape.to !== shape.from).map(makeCommentShape); 808 | if (arrows.length) builder.push(`[%cal ${arrows.join(',')}]`); 809 | if (comment.evaluation) builder.push(`[%eval ${makeEval(comment.evaluation)}]`); 810 | if (defined(comment.emt)) builder.push(`[%emt ${makeClk(comment.emt)}]`); 811 | if (defined(comment.clock)) builder.push(`[%clk ${makeClk(comment.clock)}]`); 812 | return builder.join(' '); 813 | }; 814 | 815 | export const parseComment = (comment: string): Comment => { 816 | let emt, clock, evaluation; 817 | const shapes: CommentShape[] = []; 818 | const text = comment 819 | .replace( 820 | /\s?\[%(emt|clk)\s(\d{1,5}):(\d{1,2}):(\d{1,2}(?:\.\d{0,3})?)\]\s?/g, 821 | (_, annotation, hours, minutes, seconds) => { 822 | const value = parseInt(hours, 10) * 3600 + parseInt(minutes, 10) * 60 + parseFloat(seconds); 823 | if (annotation === 'emt') emt = value; 824 | else if (annotation === 'clk') clock = value; 825 | return ' '; 826 | }, 827 | ) 828 | .replace( 829 | /\s?\[%(?:csl|cal)\s([RGYB][a-h][1-8](?:[a-h][1-8])?(?:,[RGYB][a-h][1-8](?:[a-h][1-8])?)*)\]\s?/g, 830 | (_, arrows) => { 831 | for (const arrow of arrows.split(',')) { 832 | shapes.push(parseCommentShape(arrow)!); 833 | } 834 | return ' '; 835 | }, 836 | ) 837 | .replace( 838 | /\s?\[%eval\s(?:#([+-]?\d{1,5})|([+-]?(?:\d{1,5}|\d{0,5}\.\d{1,2})))(?:,(\d{1,5}))?\]\s?/g, 839 | (_, mate, pawns, d) => { 840 | const depth = d && parseInt(d, 10); 841 | evaluation = mate ? { mate: parseInt(mate, 10), depth } : { pawns: parseFloat(pawns), depth }; 842 | return ' '; 843 | }, 844 | ) 845 | .trim(); 846 | return { 847 | text, 848 | shapes, 849 | emt, 850 | clock, 851 | evaluation, 852 | }; 853 | }; 854 | -------------------------------------------------------------------------------- /src/san.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@jest/globals'; 2 | import { Chess } from './chess.js'; 3 | import { makeFen, parseFen } from './fen.js'; 4 | import { makeSan, makeSanVariation, parseSan } from './san.js'; 5 | import { parseUci } from './util.js'; 6 | import { Antichess, Crazyhouse } from './variant.js'; 7 | 8 | test('make variation with king move', () => { 9 | const pos = Chess.default(); 10 | const variation = 'e2e4 e7e5 e1e2'.split(' ').map(uci => parseUci(uci)!); 11 | expect(makeSanVariation(pos, variation)).toBe('1. e4 e5 2. Ke2'); 12 | expect(pos).toEqual(Chess.default()); 13 | }); 14 | 15 | test('make crazyhouse variation', () => { 16 | const setup = parseFen('r4b1N~/1ppk1P2/p1b5/6p1/8/1PBPPq2/P1PR1P2/1K4N1/PNBRPPPrqnn b - - 71 36').unwrap(); 17 | const pos = Crazyhouse.fromSetup(setup).unwrap(); 18 | const variation = 'N@a3 b1b2 R@b1'.split(' ').map(uci => parseUci(uci)!); 19 | expect(makeSanVariation(pos, variation)).toBe('36... N@a3+ 37. Kb2 R@b1#'); 20 | expect(pos).toEqual(Crazyhouse.fromSetup(setup).unwrap()); 21 | }); 22 | 23 | test('make stockfish line with many knight moves', () => { 24 | const setup = parseFen('2rq1rk1/pb1nbp1p/1pn3p1/3pP3/2pP4/1N3NPQ/PP3PBP/R1B1R1K1 w - - 0 16').unwrap(); 25 | const pos = Chess.fromSetup(setup).unwrap(); 26 | const variation = 27 | 'b3d2 c6b4 e1d1 f8e8 d2f1 b4d3 f3e1 d3e1 d1e1 d7f8 f2f4 f8e6 c1e3 h7h5 f4f5 e6g5 e3g5 e7g5 f5f6 d8c7' 28 | .split(' ') 29 | .map(uci => parseUci(uci)!); 30 | expect(makeSanVariation(pos, variation)).toBe( 31 | '16. Nbd2 Nb4 17. Rd1 Re8 18. Nf1 Nd3 19. Ne1 Nxe1 20. Rxe1 Nf8 21. f4 Ne6 22. Be3 h5 23. f5 Ng5 24. Bxg5 Bxg5 25. f6 Qc7', 32 | ); 33 | expect(pos).toEqual(Chess.fromSetup(setup).unwrap()); 34 | }); 35 | 36 | test('make en passant', () => { 37 | const setup = parseFen('6bk/7b/8/3pP3/8/8/8/Q3K3 w - d6 0 2').unwrap(); 38 | const pos = Chess.fromSetup(setup).unwrap(); 39 | const move = parseUci('e5d6')!; 40 | expect(makeSan(pos, move)).toBe('exd6#'); 41 | }); 42 | 43 | test('parse basic san', () => { 44 | const pos = Chess.default(); 45 | expect(parseSan(pos, 'e4')).toEqual(parseUci('e2e4')); 46 | expect(parseSan(pos, 'Nf3')).toEqual(parseUci('g1f3')); 47 | expect(parseSan(pos, 'Nf6')).toBeUndefined(); 48 | expect(parseSan(pos, 'Ke2')).toBeUndefined(); 49 | expect(parseSan(pos, 'O-O')).toBeUndefined(); 50 | expect(parseSan(pos, 'O-O-O')).toBeUndefined(); 51 | expect(parseSan(pos, 'Q@e3')).toBeUndefined(); 52 | }); 53 | 54 | test('parse fools mate', () => { 55 | const pos = Chess.default(); 56 | const line = ['e4', 'e5', 'Qh5', 'Nf6', 'Bc4', 'Nc6', 'Qxf7#']; 57 | for (const san of line) pos.play(parseSan(pos, san)!); 58 | expect(pos.isCheckmate()).toBe(true); 59 | }); 60 | 61 | test('parse pawn capture', () => { 62 | let pos = Chess.default(); 63 | const line = ['e4', 'd5', 'c4', 'Nf6', 'exd5']; 64 | for (const san of line) pos.play(parseSan(pos, san)!); 65 | expect(makeFen(pos.toSetup())).toBe('rnbqkb1r/ppp1pppp/5n2/3P4/2P5/8/PP1P1PPP/RNBQKBNR b KQkq - 0 3'); 66 | 67 | pos = Chess.fromSetup(parseFen('r4br1/pp1Npkp1/2P4p/5P2/6P1/5KnP/PP6/R1B5 b - -').unwrap()).unwrap(); 68 | expect(parseSan(pos, 'bxc6')).toEqual({ from: 49, to: 42 }); 69 | 70 | pos = Chess.fromSetup(parseFen('2rq1rk1/pb2bppp/1p2p3/n1ppPn2/2PP4/PP3N2/1B1NQPPP/RB3RK1 b - -').unwrap()).unwrap(); 71 | expect(parseSan(pos, 'c4')).toBeUndefined(); // missing file 72 | }); 73 | 74 | test('parse antichess', () => { 75 | const pos = Antichess.default(); 76 | const line = [ 77 | 'g3', 78 | 'Nh6', 79 | 'g4', 80 | 'Nxg4', 81 | 'b3', 82 | 'Nxh2', 83 | 'Rxh2', 84 | 'g5', 85 | 'Rxh7', 86 | 'Rxh7', 87 | 'Bh3', 88 | 'Rxh3', 89 | 'Nxh3', 90 | 'Na6', 91 | 'Nxg5', 92 | 'Nb4', 93 | 'Nxf7', 94 | 'Nxc2', 95 | 'Qxc2', 96 | 'Kxf7', 97 | 'Qxc7', 98 | 'Qxc7', 99 | 'a4', 100 | 'Qxc1', 101 | 'Ra3', 102 | 'Qxa3', 103 | 'Nxa3', 104 | 'b5', 105 | 'Nxb5', 106 | 'Rb8', 107 | 'Nxa7', 108 | 'Rxb3', 109 | 'Nxc8', 110 | 'Rg3', 111 | 'Nxe7', 112 | 'Bxe7', 113 | 'fxg3', 114 | 'Bh4', 115 | 'gxh4', 116 | 'd5', 117 | 'e4', 118 | 'dxe4', 119 | 'd3', 120 | 'exd3', 121 | 'Kf1', 122 | 'd2', 123 | 'Kg1', 124 | 'Kf6', 125 | 'a5', 126 | 'Ke6', 127 | 'a6', 128 | 'Kd7', 129 | 'a7', 130 | 'Kc7', 131 | 'h5', 132 | 'd1=B', 133 | 'a8=B', 134 | 'Bxh5', 135 | 'Bf3', 136 | 'Bxf3', 137 | 'Kg2', 138 | 'Bxg2#', 139 | ]; 140 | for (const san of line) pos.play(parseSan(pos, san)!); 141 | expect(makeFen(pos.toSetup())).toBe('8/2k5/8/8/8/8/6b1/8 w - - 0 32'); 142 | }); 143 | 144 | test('parse crazyhouse', () => { 145 | const pos = Crazyhouse.default(); 146 | const line = [ 147 | 'd4', 148 | 'd5', 149 | 'Nc3', 150 | 'Bf5', 151 | 'e3', 152 | 'e6', 153 | 'Bd3', 154 | 'Bg6', 155 | 'Nf3', 156 | 'Bd6', 157 | 'O-O', 158 | 'Ne7', 159 | 'g3', 160 | 'Nbc6', 161 | 'Re1', 162 | 'O-O', 163 | 'Ne2', 164 | 'e5', 165 | 'dxe5', 166 | 'Nxe5', 167 | 'Nxe5', 168 | 'Bxe5', 169 | 'f4', 170 | 'N@f3+', 171 | 'Kg2', 172 | 'Nxe1+', 173 | 'Qxe1', 174 | 'Bd6', 175 | '@f3', 176 | '@e4', 177 | 'fxe4', 178 | 'dxe4', 179 | 'Bc4', 180 | '@f3+', 181 | 'Kf2', 182 | 'fxe2', 183 | 'Qxe2', 184 | 'N@h3+', 185 | 'Kg2', 186 | 'R@f2+', 187 | 'Qxf2', 188 | 'Nxf2', 189 | 'Kxf2', 190 | 'Q@f3+', 191 | 'Ke1', 192 | 'Bxf4', 193 | 'gxf4', 194 | 'Qdd1#', 195 | ]; 196 | for (const san of line) pos.play(parseSan(pos, san)!); 197 | expect(makeFen(pos.toSetup())).toBe('r4rk1/ppp1nppp/6b1/8/2B1pP2/4Pq2/PPP4P/R1BqK3[PPNNNBRp] w - - 1 25'); 198 | }); 199 | 200 | test('overspecified pawn move', () => { 201 | const pos = Chess.default(); 202 | expect(parseSan(pos, '2e4')).toEqual({ from: 12, to: 28 }); 203 | }); 204 | 205 | test.each([ 206 | ['N3k2N/8/8/3N4/N4N1N/2R5/1R6/4K3 w - -', 'e1f1', 'Kf1'], 207 | ['N3k2N/8/8/3N4/N4N1N/2R5/1R6/4K3 w - -', 'c3c2', 'Rcc2'], 208 | ['N3k2N/8/8/3N4/N4N1N/2R5/1R6/4K3 w - -', 'b2c2', 'Rbc2'], 209 | ['N3k2N/8/8/3N4/N4N1N/2R5/1R6/4K3 w - -', 'a4b6', 'N4b6'], 210 | ['N3k2N/8/8/3N4/N4N1N/2R5/1R6/4K3 w - -', 'h8g6', 'N8g6'], 211 | ['N3k2N/8/8/3N4/N4N1N/2R5/1R6/4K3 w - -', 'h4g6', 'Nh4g6'], 212 | ['8/2KN1p2/5p2/3N1B1k/5PNp/7P/7P/8 w - -', 'd5f6', 'N5xf6#'], 213 | ['8/8/8/R2nkn2/8/8/2K5/8 b - -', 'f5e3', 'Ne3+'], 214 | ['7k/1p2Npbp/8/2P5/1P1r4/3b2QP/3q1pPK/2RB4 b - -', 'f2f1q', 'f1=Q'], 215 | ['7k/1p2Npbp/8/2P5/1P1r4/3b2QP/3q1pPK/2RB4 b - -', 'f2f1n', 'f1=N+'], 216 | ])('disambiguation', (fen, uci, san) => { 217 | const pos = Chess.fromSetup(parseFen(fen).unwrap()).unwrap(); 218 | const move = parseUci(uci)!; 219 | expect(makeSan(pos, move)).toBe(san); 220 | }); 221 | -------------------------------------------------------------------------------- /src/san.ts: -------------------------------------------------------------------------------- 1 | import { attacks, bishopAttacks, kingAttacks, knightAttacks, queenAttacks, rookAttacks } from './attacks.js'; 2 | import { Position } from './chess.js'; 3 | import { SquareSet } from './squareSet.js'; 4 | import { CastlingSide, FILE_NAMES, isDrop, Move, RANK_NAMES, SquareName } from './types.js'; 5 | import { charToRole, defined, makeSquare, opposite, parseSquare, roleToChar, squareFile, squareRank } from './util.js'; 6 | 7 | const makeSanWithoutSuffix = (pos: Position, move: Move): string => { 8 | let san = ''; 9 | if (isDrop(move)) { 10 | if (move.role !== 'pawn') san = roleToChar(move.role).toUpperCase(); 11 | san += '@' + makeSquare(move.to); 12 | } else { 13 | const role = pos.board.getRole(move.from); 14 | if (!role) return '--'; 15 | if (role === 'king' && (pos.board[pos.turn].has(move.to) || Math.abs(move.to - move.from) === 2)) { 16 | san = move.to > move.from ? 'O-O' : 'O-O-O'; 17 | } else { 18 | const capture = pos.board.occupied.has(move.to) 19 | || (role === 'pawn' && squareFile(move.from) !== squareFile(move.to)); 20 | if (role !== 'pawn') { 21 | san = roleToChar(role).toUpperCase(); 22 | 23 | // Disambiguation 24 | let others; 25 | if (role === 'king') others = kingAttacks(move.to).intersect(pos.board.king); 26 | else if (role === 'queen') others = queenAttacks(move.to, pos.board.occupied).intersect(pos.board.queen); 27 | else if (role === 'rook') others = rookAttacks(move.to, pos.board.occupied).intersect(pos.board.rook); 28 | else if (role === 'bishop') others = bishopAttacks(move.to, pos.board.occupied).intersect(pos.board.bishop); 29 | else others = knightAttacks(move.to).intersect(pos.board.knight); 30 | others = others.intersect(pos.board[pos.turn]).without(move.from); 31 | if (others.nonEmpty()) { 32 | const ctx = pos.ctx(); 33 | for (const from of others) { 34 | if (!pos.dests(from, ctx).has(move.to)) others = others.without(from); 35 | } 36 | if (others.nonEmpty()) { 37 | let row = false; 38 | let column = others.intersects(SquareSet.fromRank(squareRank(move.from))); 39 | if (others.intersects(SquareSet.fromFile(squareFile(move.from)))) row = true; 40 | else column = true; 41 | if (column) san += FILE_NAMES[squareFile(move.from)]; 42 | if (row) san += RANK_NAMES[squareRank(move.from)]; 43 | } 44 | } 45 | } else if (capture) san = FILE_NAMES[squareFile(move.from)]; 46 | 47 | if (capture) san += 'x'; 48 | san += makeSquare(move.to); 49 | if (move.promotion) san += '=' + roleToChar(move.promotion).toUpperCase(); 50 | } 51 | } 52 | return san; 53 | }; 54 | 55 | export const makeSanAndPlay = (pos: Position, move: Move): string => { 56 | const san = makeSanWithoutSuffix(pos, move); 57 | pos.play(move); 58 | if (pos.outcome()?.winner) return san + '#'; 59 | if (pos.isCheck()) return san + '+'; 60 | return san; 61 | }; 62 | 63 | export const makeSanVariation = (pos: Position, variation: Move[]): string => { 64 | pos = pos.clone(); 65 | const line = []; 66 | for (let i = 0; i < variation.length; i++) { 67 | if (i !== 0) line.push(' '); 68 | if (pos.turn === 'white') line.push(pos.fullmoves, '. '); 69 | else if (i === 0) line.push(pos.fullmoves, '... '); 70 | const san = makeSanWithoutSuffix(pos, variation[i]); 71 | pos.play(variation[i]); 72 | line.push(san); 73 | if (san === '--') return line.join(''); 74 | if (i === variation.length - 1 && pos.outcome()?.winner) line.push('#'); 75 | else if (pos.isCheck()) line.push('+'); 76 | } 77 | return line.join(''); 78 | }; 79 | 80 | export const makeSan = (pos: Position, move: Move): string => makeSanAndPlay(pos.clone(), move); 81 | 82 | export const parseSan = (pos: Position, san: string): Move | undefined => { 83 | const ctx = pos.ctx(); 84 | 85 | // Normal move 86 | const match = san.match(/^([NBRQK])?([a-h])?([1-8])?[-x]?([a-h][1-8])(?:=?([nbrqkNBRQK]))?[+#]?$/) as 87 | | [ 88 | string, 89 | 'N' | 'B' | 'R' | 'Q' | 'K' | undefined, 90 | string | undefined, 91 | string | undefined, 92 | SquareName, 93 | 'n' | 'b' | 'r' | 'q' | 'k' | 'N' | 'B' | 'R' | 'Q' | 'K' | undefined, 94 | ] 95 | | null; 96 | if (!match) { 97 | // Castling 98 | let castlingSide: CastlingSide | undefined; 99 | if (san === 'O-O' || san === 'O-O+' || san === 'O-O#') castlingSide = 'h'; 100 | else if (san === 'O-O-O' || san === 'O-O-O+' || san === 'O-O-O#') castlingSide = 'a'; 101 | if (castlingSide) { 102 | const rook = pos.castles.rook[pos.turn][castlingSide]; 103 | if (!defined(ctx.king) || !defined(rook) || !pos.dests(ctx.king, ctx).has(rook)) return; 104 | return { 105 | from: ctx.king, 106 | to: rook, 107 | }; 108 | } 109 | 110 | // Drop 111 | const match = san.match(/^([pnbrqkPNBRQK])?@([a-h][1-8])[+#]?$/) as 112 | | [string, 'p' | 'n' | 'b' | 'r' | 'q' | 'k' | 'P' | 'N' | 'B' | 'R' | 'Q' | 'K' | undefined, SquareName] 113 | | null; 114 | if (!match) return; 115 | const move = { 116 | role: match[1] ? charToRole(match[1]) : 'pawn', 117 | to: parseSquare(match[2]), 118 | }; 119 | return pos.isLegal(move, ctx) ? move : undefined; 120 | } 121 | const role = match[1] ? charToRole(match[1]) : 'pawn'; 122 | const to = parseSquare(match[4]); 123 | 124 | const promotion = match[5] ? charToRole(match[5]) : undefined; 125 | if (!!promotion !== (role === 'pawn' && SquareSet.backranks().has(to))) return; 126 | if (promotion === 'king' && pos.rules !== 'antichess') return; 127 | 128 | let candidates = pos.board.pieces(pos.turn, role); 129 | if (role === 'pawn' && !match[2]) candidates = candidates.intersect(SquareSet.fromFile(squareFile(to))); 130 | else if (match[2]) candidates = candidates.intersect(SquareSet.fromFile(match[2].charCodeAt(0) - 'a'.charCodeAt(0))); 131 | if (match[3]) candidates = candidates.intersect(SquareSet.fromRank(match[3].charCodeAt(0) - '1'.charCodeAt(0))); 132 | 133 | // Optimization: Reduce set of candidates 134 | const pawnAdvance = role === 'pawn' ? SquareSet.fromFile(squareFile(to)) : SquareSet.empty(); 135 | candidates = candidates.intersect( 136 | pawnAdvance.union(attacks({ color: opposite(pos.turn), role }, to, pos.board.occupied)), 137 | ); 138 | 139 | // Check uniqueness and legality 140 | let from; 141 | for (const candidate of candidates) { 142 | if (pos.dests(candidate, ctx).has(to)) { 143 | if (defined(from)) return; // Ambiguous 144 | from = candidate; 145 | } 146 | } 147 | if (!defined(from)) return; // Illegal 148 | 149 | return { 150 | from, 151 | to, 152 | promotion, 153 | }; 154 | }; 155 | -------------------------------------------------------------------------------- /src/setup.ts: -------------------------------------------------------------------------------- 1 | import { Board, boardEquals } from './board.js'; 2 | import { SquareSet } from './squareSet.js'; 3 | import { ByColor, ByRole, Color, Role, ROLES, Square } from './types.js'; 4 | 5 | export class MaterialSide implements ByRole { 6 | pawn: number; 7 | knight: number; 8 | bishop: number; 9 | rook: number; 10 | queen: number; 11 | king: number; 12 | 13 | private constructor() {} 14 | 15 | static empty(): MaterialSide { 16 | const m = new MaterialSide(); 17 | for (const role of ROLES) m[role] = 0; 18 | return m; 19 | } 20 | 21 | static fromBoard(board: Board, color: Color): MaterialSide { 22 | const m = new MaterialSide(); 23 | for (const role of ROLES) m[role] = board.pieces(color, role).size(); 24 | return m; 25 | } 26 | 27 | clone(): MaterialSide { 28 | const m = new MaterialSide(); 29 | for (const role of ROLES) m[role] = this[role]; 30 | return m; 31 | } 32 | 33 | equals(other: MaterialSide): boolean { 34 | return ROLES.every(role => this[role] === other[role]); 35 | } 36 | 37 | add(other: MaterialSide): MaterialSide { 38 | const m = new MaterialSide(); 39 | for (const role of ROLES) m[role] = this[role] + other[role]; 40 | return m; 41 | } 42 | 43 | subtract(other: MaterialSide): MaterialSide { 44 | const m = new MaterialSide(); 45 | for (const role of ROLES) m[role] = this[role] - other[role]; 46 | return m; 47 | } 48 | 49 | nonEmpty(): boolean { 50 | return ROLES.some(role => this[role] > 0); 51 | } 52 | 53 | isEmpty(): boolean { 54 | return !this.nonEmpty(); 55 | } 56 | 57 | hasPawns(): boolean { 58 | return this.pawn > 0; 59 | } 60 | 61 | hasNonPawns(): boolean { 62 | return this.knight > 0 || this.bishop > 0 || this.rook > 0 || this.queen > 0 || this.king > 0; 63 | } 64 | 65 | size(): number { 66 | return this.pawn + this.knight + this.bishop + this.rook + this.queen + this.king; 67 | } 68 | } 69 | 70 | export class Material implements ByColor { 71 | constructor( 72 | public white: MaterialSide, 73 | public black: MaterialSide, 74 | ) {} 75 | 76 | static empty(): Material { 77 | return new Material(MaterialSide.empty(), MaterialSide.empty()); 78 | } 79 | 80 | static fromBoard(board: Board): Material { 81 | return new Material(MaterialSide.fromBoard(board, 'white'), MaterialSide.fromBoard(board, 'black')); 82 | } 83 | 84 | clone(): Material { 85 | return new Material(this.white.clone(), this.black.clone()); 86 | } 87 | 88 | equals(other: Material): boolean { 89 | return this.white.equals(other.white) && this.black.equals(other.black); 90 | } 91 | 92 | add(other: Material): Material { 93 | return new Material(this.white.add(other.white), this.black.add(other.black)); 94 | } 95 | 96 | subtract(other: Material): Material { 97 | return new Material(this.white.subtract(other.white), this.black.subtract(other.black)); 98 | } 99 | 100 | count(role: Role): number { 101 | return this.white[role] + this.black[role]; 102 | } 103 | 104 | size(): number { 105 | return this.white.size() + this.black.size(); 106 | } 107 | 108 | isEmpty(): boolean { 109 | return this.white.isEmpty() && this.black.isEmpty(); 110 | } 111 | 112 | nonEmpty(): boolean { 113 | return !this.isEmpty(); 114 | } 115 | 116 | hasPawns(): boolean { 117 | return this.white.hasPawns() || this.black.hasPawns(); 118 | } 119 | 120 | hasNonPawns(): boolean { 121 | return this.white.hasNonPawns() || this.black.hasNonPawns(); 122 | } 123 | } 124 | 125 | export class RemainingChecks implements ByColor { 126 | constructor( 127 | public white: number, 128 | public black: number, 129 | ) {} 130 | 131 | static default(): RemainingChecks { 132 | return new RemainingChecks(3, 3); 133 | } 134 | 135 | clone(): RemainingChecks { 136 | return new RemainingChecks(this.white, this.black); 137 | } 138 | 139 | equals(other: RemainingChecks): boolean { 140 | return this.white === other.white && this.black === other.black; 141 | } 142 | } 143 | 144 | /** 145 | * A not necessarily legal chess or chess variant position. 146 | */ 147 | export interface Setup { 148 | board: Board; 149 | pockets: Material | undefined; 150 | turn: Color; 151 | castlingRights: SquareSet; 152 | epSquare: Square | undefined; 153 | remainingChecks: RemainingChecks | undefined; 154 | halfmoves: number; 155 | fullmoves: number; 156 | } 157 | 158 | export const defaultSetup = (): Setup => ({ 159 | board: Board.default(), 160 | pockets: undefined, 161 | turn: 'white', 162 | castlingRights: SquareSet.corners(), 163 | epSquare: undefined, 164 | remainingChecks: undefined, 165 | halfmoves: 0, 166 | fullmoves: 1, 167 | }); 168 | 169 | export const setupClone = (setup: Setup): Setup => ({ 170 | board: setup.board.clone(), 171 | pockets: setup.pockets?.clone(), 172 | turn: setup.turn, 173 | castlingRights: setup.castlingRights, 174 | epSquare: setup.epSquare, 175 | remainingChecks: setup.remainingChecks?.clone(), 176 | halfmoves: setup.halfmoves, 177 | fullmoves: setup.fullmoves, 178 | }); 179 | 180 | export const setupEquals = (left: Setup, right: Setup): boolean => 181 | boardEquals(left.board, right.board) 182 | && ((right.pockets && left.pockets?.equals(right.pockets)) || (!left.pockets && !right.pockets)) 183 | && left.turn === right.turn 184 | && left.castlingRights.equals(right.castlingRights) 185 | && left.epSquare === right.epSquare 186 | && ((right.remainingChecks && left.remainingChecks?.equals(right.remainingChecks)) 187 | || (!left.remainingChecks && !right.remainingChecks)) 188 | && left.halfmoves === right.halfmoves 189 | && left.fullmoves === right.fullmoves; 190 | -------------------------------------------------------------------------------- /src/squareSet.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@jest/globals'; 2 | import { SquareSet } from './squareSet.js'; 3 | 4 | test('full set has all', () => { 5 | for (let square = 0; square < 64; square++) { 6 | expect(SquareSet.full().has(square)).toBe(true); 7 | } 8 | }); 9 | 10 | test('size', () => { 11 | let squares = SquareSet.empty(); 12 | for (let i = 0; i < 64; i++) { 13 | expect(squares.size()).toBe(i); 14 | squares = squares.with(i); 15 | } 16 | }); 17 | 18 | test('shr64', () => { 19 | const r = new SquareSet(0xe0a1222, 0x1e222212); 20 | expect(r.shr64(0)).toEqual(r); 21 | expect(r.shr64(1)).toEqual(new SquareSet(0x7050911, 0xf111109)); 22 | expect(r.shr64(3)).toEqual(new SquareSet(0x41c14244, 0x3c44442)); 23 | expect(r.shr64(31)).toEqual(new SquareSet(0x3c444424, 0x0)); 24 | expect(r.shr64(32)).toEqual(new SquareSet(0x1e222212, 0x0)); 25 | expect(r.shr64(33)).toEqual(new SquareSet(0xf111109, 0x0)); 26 | expect(r.shr64(62)).toEqual(new SquareSet(0x0, 0x0)); 27 | }); 28 | 29 | test('shl64', () => { 30 | const r = new SquareSet(0xe0a1222, 0x1e222212); 31 | expect(r.shl64(0)).toEqual(r); 32 | expect(r.shl64(1)).toEqual(new SquareSet(0x1c142444, 0x3c444424)); 33 | expect(r.shl64(3)).toEqual(new SquareSet(0x70509110, 0xf1111090)); 34 | expect(r.shl64(31)).toEqual(new SquareSet(0x0, 0x7050911)); 35 | expect(r.shl64(32)).toEqual(new SquareSet(0x0, 0xe0a1222)); 36 | expect(r.shl64(33)).toEqual(new SquareSet(0x0, 0x1c142444)); 37 | expect(r.shl64(62)).toEqual(new SquareSet(0x0, 0x80000000)); 38 | expect(r.shl64(63)).toEqual(new SquareSet(0x0, 0x0)); 39 | }); 40 | 41 | test('more than one', () => { 42 | expect(new SquareSet(0, 0).moreThanOne()).toBe(false); 43 | expect(new SquareSet(1, 0).moreThanOne()).toBe(false); 44 | expect(new SquareSet(2, 0).moreThanOne()).toBe(false); 45 | expect(new SquareSet(4, 0).moreThanOne()).toBe(false); 46 | expect(new SquareSet(-2147483648, 0).moreThanOne()).toBe(false); 47 | expect(new SquareSet(0, 1).moreThanOne()).toBe(false); 48 | expect(new SquareSet(0, 2).moreThanOne()).toBe(false); 49 | expect(new SquareSet(0, 4).moreThanOne()).toBe(false); 50 | expect(new SquareSet(0, -2147483648).moreThanOne()).toBe(false); 51 | 52 | expect(new SquareSet(1, 1).moreThanOne()).toBe(true); 53 | expect(new SquareSet(3, 0).moreThanOne()).toBe(true); 54 | expect(new SquareSet(-1, 0).moreThanOne()).toBe(true); 55 | expect(new SquareSet(0, 3).moreThanOne()).toBe(true); 56 | expect(new SquareSet(0, -1).moreThanOne()).toBe(true); 57 | }); 58 | -------------------------------------------------------------------------------- /src/squareSet.ts: -------------------------------------------------------------------------------- 1 | import { Color, Square } from './types.js'; 2 | 3 | const popcnt32 = (n: number): number => { 4 | n = n - ((n >>> 1) & 0x5555_5555); 5 | n = (n & 0x3333_3333) + ((n >>> 2) & 0x3333_3333); 6 | return Math.imul((n + (n >>> 4)) & 0x0f0f_0f0f, 0x0101_0101) >> 24; 7 | }; 8 | 9 | const bswap32 = (n: number): number => { 10 | n = ((n >>> 8) & 0x00ff_00ff) | ((n & 0x00ff_00ff) << 8); 11 | return ((n >>> 16) & 0xffff) | ((n & 0xffff) << 16); 12 | }; 13 | 14 | const rbit32 = (n: number): number => { 15 | n = ((n >>> 1) & 0x5555_5555) | ((n & 0x5555_5555) << 1); 16 | n = ((n >>> 2) & 0x3333_3333) | ((n & 0x3333_3333) << 2); 17 | n = ((n >>> 4) & 0x0f0f_0f0f) | ((n & 0x0f0f_0f0f) << 4); 18 | return bswap32(n); 19 | }; 20 | 21 | /** 22 | * An immutable set of squares, implemented as a bitboard. 23 | */ 24 | export class SquareSet implements Iterable { 25 | readonly lo: number; 26 | readonly hi: number; 27 | 28 | constructor(lo: number, hi: number) { 29 | this.lo = lo | 0; 30 | this.hi = hi | 0; 31 | } 32 | 33 | static fromSquare(square: Square): SquareSet { 34 | return square >= 32 ? new SquareSet(0, 1 << (square - 32)) : new SquareSet(1 << square, 0); 35 | } 36 | 37 | static fromRank(rank: number): SquareSet { 38 | return new SquareSet(0xff, 0).shl64(8 * rank); 39 | } 40 | 41 | static fromFile(file: number): SquareSet { 42 | return new SquareSet(0x0101_0101 << file, 0x0101_0101 << file); 43 | } 44 | 45 | static empty(): SquareSet { 46 | return new SquareSet(0, 0); 47 | } 48 | 49 | static full(): SquareSet { 50 | return new SquareSet(0xffff_ffff, 0xffff_ffff); 51 | } 52 | 53 | static corners(): SquareSet { 54 | return new SquareSet(0x81, 0x8100_0000); 55 | } 56 | 57 | static center(): SquareSet { 58 | return new SquareSet(0x1800_0000, 0x18); 59 | } 60 | 61 | static backranks(): SquareSet { 62 | return new SquareSet(0xff, 0xff00_0000); 63 | } 64 | 65 | static backrank(color: Color): SquareSet { 66 | return color === 'white' ? new SquareSet(0xff, 0) : new SquareSet(0, 0xff00_0000); 67 | } 68 | 69 | static lightSquares(): SquareSet { 70 | return new SquareSet(0x55aa_55aa, 0x55aa_55aa); 71 | } 72 | 73 | static darkSquares(): SquareSet { 74 | return new SquareSet(0xaa55_aa55, 0xaa55_aa55); 75 | } 76 | 77 | complement(): SquareSet { 78 | return new SquareSet(~this.lo, ~this.hi); 79 | } 80 | 81 | xor(other: SquareSet): SquareSet { 82 | return new SquareSet(this.lo ^ other.lo, this.hi ^ other.hi); 83 | } 84 | 85 | union(other: SquareSet): SquareSet { 86 | return new SquareSet(this.lo | other.lo, this.hi | other.hi); 87 | } 88 | 89 | intersect(other: SquareSet): SquareSet { 90 | return new SquareSet(this.lo & other.lo, this.hi & other.hi); 91 | } 92 | 93 | diff(other: SquareSet): SquareSet { 94 | return new SquareSet(this.lo & ~other.lo, this.hi & ~other.hi); 95 | } 96 | 97 | intersects(other: SquareSet): boolean { 98 | return this.intersect(other).nonEmpty(); 99 | } 100 | 101 | isDisjoint(other: SquareSet): boolean { 102 | return this.intersect(other).isEmpty(); 103 | } 104 | 105 | supersetOf(other: SquareSet): boolean { 106 | return other.diff(this).isEmpty(); 107 | } 108 | 109 | subsetOf(other: SquareSet): boolean { 110 | return this.diff(other).isEmpty(); 111 | } 112 | 113 | shr64(shift: number): SquareSet { 114 | if (shift >= 64) return SquareSet.empty(); 115 | if (shift >= 32) return new SquareSet(this.hi >>> (shift - 32), 0); 116 | if (shift > 0) return new SquareSet((this.lo >>> shift) ^ (this.hi << (32 - shift)), this.hi >>> shift); 117 | return this; 118 | } 119 | 120 | shl64(shift: number): SquareSet { 121 | if (shift >= 64) return SquareSet.empty(); 122 | if (shift >= 32) return new SquareSet(0, this.lo << (shift - 32)); 123 | if (shift > 0) return new SquareSet(this.lo << shift, (this.hi << shift) ^ (this.lo >>> (32 - shift))); 124 | return this; 125 | } 126 | 127 | bswap64(): SquareSet { 128 | return new SquareSet(bswap32(this.hi), bswap32(this.lo)); 129 | } 130 | 131 | rbit64(): SquareSet { 132 | return new SquareSet(rbit32(this.hi), rbit32(this.lo)); 133 | } 134 | 135 | minus64(other: SquareSet): SquareSet { 136 | const lo = this.lo - other.lo; 137 | const c = ((lo & other.lo & 1) + (other.lo >>> 1) + (lo >>> 1)) >>> 31; 138 | return new SquareSet(lo, this.hi - (other.hi + c)); 139 | } 140 | 141 | equals(other: SquareSet): boolean { 142 | return this.lo === other.lo && this.hi === other.hi; 143 | } 144 | 145 | size(): number { 146 | return popcnt32(this.lo) + popcnt32(this.hi); 147 | } 148 | 149 | isEmpty(): boolean { 150 | return this.lo === 0 && this.hi === 0; 151 | } 152 | 153 | nonEmpty(): boolean { 154 | return this.lo !== 0 || this.hi !== 0; 155 | } 156 | 157 | has(square: Square): boolean { 158 | return (square >= 32 ? this.hi & (1 << (square - 32)) : this.lo & (1 << square)) !== 0; 159 | } 160 | 161 | set(square: Square, on: boolean): SquareSet { 162 | return on ? this.with(square) : this.without(square); 163 | } 164 | 165 | with(square: Square): SquareSet { 166 | return square >= 32 167 | ? new SquareSet(this.lo, this.hi | (1 << (square - 32))) 168 | : new SquareSet(this.lo | (1 << square), this.hi); 169 | } 170 | 171 | without(square: Square): SquareSet { 172 | return square >= 32 173 | ? new SquareSet(this.lo, this.hi & ~(1 << (square - 32))) 174 | : new SquareSet(this.lo & ~(1 << square), this.hi); 175 | } 176 | 177 | toggle(square: Square): SquareSet { 178 | return square >= 32 179 | ? new SquareSet(this.lo, this.hi ^ (1 << (square - 32))) 180 | : new SquareSet(this.lo ^ (1 << square), this.hi); 181 | } 182 | 183 | last(): Square | undefined { 184 | if (this.hi !== 0) return 63 - Math.clz32(this.hi); 185 | if (this.lo !== 0) return 31 - Math.clz32(this.lo); 186 | return; 187 | } 188 | 189 | first(): Square | undefined { 190 | if (this.lo !== 0) return 31 - Math.clz32(this.lo & -this.lo); 191 | if (this.hi !== 0) return 63 - Math.clz32(this.hi & -this.hi); 192 | return; 193 | } 194 | 195 | withoutFirst(): SquareSet { 196 | if (this.lo !== 0) return new SquareSet(this.lo & (this.lo - 1), this.hi); 197 | return new SquareSet(0, this.hi & (this.hi - 1)); 198 | } 199 | 200 | moreThanOne(): boolean { 201 | return (this.hi !== 0 && this.lo !== 0) || (this.lo & (this.lo - 1)) !== 0 || (this.hi & (this.hi - 1)) !== 0; 202 | } 203 | 204 | singleSquare(): Square | undefined { 205 | return this.moreThanOne() ? undefined : this.last(); 206 | } 207 | 208 | *[Symbol.iterator](): Iterator { 209 | let lo = this.lo; 210 | let hi = this.hi; 211 | while (lo !== 0) { 212 | const idx = 31 - Math.clz32(lo & -lo); 213 | lo ^= 1 << idx; 214 | yield idx; 215 | } 216 | while (hi !== 0) { 217 | const idx = 31 - Math.clz32(hi & -hi); 218 | hi ^= 1 << idx; 219 | yield 32 + idx; 220 | } 221 | } 222 | 223 | *reversed(): Iterable { 224 | let lo = this.lo; 225 | let hi = this.hi; 226 | while (hi !== 0) { 227 | const idx = 31 - Math.clz32(hi); 228 | hi ^= 1 << idx; 229 | yield 32 + idx; 230 | } 231 | while (lo !== 0) { 232 | const idx = 31 - Math.clz32(lo); 233 | lo ^= 1 << idx; 234 | yield idx; 235 | } 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /src/transform.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@jest/globals'; 2 | import { SquareSet } from './squareSet.js'; 3 | import { flipDiagonal, flipHorizontal, flipVertical, rotate180 } from './transform.js'; 4 | 5 | const r = new SquareSet(0x0e0a1222, 0x1e222212); 6 | 7 | test('flip vertical', () => { 8 | expect(flipVertical(SquareSet.full())).toEqual(SquareSet.full()); 9 | expect(flipVertical(r)).toEqual(new SquareSet(0x1222221e, 0x22120a0e)); 10 | }); 11 | 12 | test('flip horizontal', () => { 13 | expect(flipHorizontal(SquareSet.full())).toEqual(SquareSet.full()); 14 | expect(flipHorizontal(r)).toEqual(new SquareSet(0x70504844, 0x78444448)); 15 | }); 16 | 17 | test('flip diagonal', () => { 18 | expect(flipDiagonal(SquareSet.full())).toEqual(SquareSet.full()); 19 | expect(flipDiagonal(r)).toEqual(new SquareSet(0x8c88ff00, 0x00006192)); 20 | }); 21 | 22 | test('rotate 180', () => { 23 | expect(rotate180(SquareSet.full())).toEqual(SquareSet.full()); 24 | expect(rotate180(r)).toEqual(new SquareSet(0x48444478, 0x44485070)); 25 | }); 26 | -------------------------------------------------------------------------------- /src/transform.ts: -------------------------------------------------------------------------------- 1 | import { Board } from './board.js'; 2 | import { Setup } from './setup.js'; 3 | import { SquareSet } from './squareSet.js'; 4 | import { COLORS, ROLES } from './types.js'; 5 | import { defined } from './util.js'; 6 | 7 | export const flipVertical = (s: SquareSet): SquareSet => s.bswap64(); 8 | 9 | export const flipHorizontal = (s: SquareSet): SquareSet => { 10 | const k1 = new SquareSet(0x55555555, 0x55555555); 11 | const k2 = new SquareSet(0x33333333, 0x33333333); 12 | const k4 = new SquareSet(0x0f0f0f0f, 0x0f0f0f0f); 13 | s = s.shr64(1).intersect(k1).union(s.intersect(k1).shl64(1)); 14 | s = s.shr64(2).intersect(k2).union(s.intersect(k2).shl64(2)); 15 | s = s.shr64(4).intersect(k4).union(s.intersect(k4).shl64(4)); 16 | return s; 17 | }; 18 | 19 | export const flipDiagonal = (s: SquareSet): SquareSet => { 20 | let t = s.xor(s.shl64(28)).intersect(new SquareSet(0, 0x0f0f0f0f)); 21 | s = s.xor(t.xor(t.shr64(28))); 22 | t = s.xor(s.shl64(14)).intersect(new SquareSet(0x33330000, 0x33330000)); 23 | s = s.xor(t.xor(t.shr64(14))); 24 | t = s.xor(s.shl64(7)).intersect(new SquareSet(0x55005500, 0x55005500)); 25 | s = s.xor(t.xor(t.shr64(7))); 26 | return s; 27 | }; 28 | 29 | export const rotate180 = (s: SquareSet): SquareSet => s.rbit64(); 30 | 31 | export const transformBoard = (board: Board, f: (s: SquareSet) => SquareSet): Board => { 32 | const b = Board.empty(); 33 | b.occupied = f(board.occupied); 34 | b.promoted = f(board.promoted); 35 | for (const color of COLORS) b[color] = f(board[color]); 36 | for (const role of ROLES) b[role] = f(board[role]); 37 | return b; 38 | }; 39 | 40 | export const transformSetup = (setup: Setup, f: (s: SquareSet) => SquareSet): Setup => ({ 41 | board: transformBoard(setup.board, f), 42 | pockets: setup.pockets?.clone(), 43 | turn: setup.turn, 44 | castlingRights: f(setup.castlingRights), 45 | epSquare: defined(setup.epSquare) ? f(SquareSet.fromSquare(setup.epSquare)).first() : undefined, 46 | remainingChecks: setup.remainingChecks?.clone(), 47 | halfmoves: setup.halfmoves, 48 | fullmoves: setup.fullmoves, 49 | }); 50 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export const FILE_NAMES = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'] as const; 2 | 3 | export type FileName = (typeof FILE_NAMES)[number]; 4 | 5 | export const RANK_NAMES = ['1', '2', '3', '4', '5', '6', '7', '8'] as const; 6 | 7 | export type RankName = (typeof RANK_NAMES)[number]; 8 | 9 | export type Square = number; 10 | 11 | export type SquareName = `${FileName}${RankName}`; 12 | 13 | export const ROLE_CHARS = ['q', 'n', 'r', 'b', 'p', 'k'] as const; 14 | 15 | export type LowerCaseRoleChar = (typeof ROLE_CHARS)[number]; 16 | 17 | export type RoleChar = LowerCaseRoleChar | Uppercase; 18 | 19 | /** 20 | * Indexable by square indices. 21 | */ 22 | export type BySquare = T[]; 23 | 24 | export const COLORS = ['white', 'black'] as const; 25 | 26 | export type Color = (typeof COLORS)[number]; 27 | 28 | /** 29 | * Indexable by `white` and `black`. 30 | */ 31 | export type ByColor = { 32 | [color in Color]: T; 33 | }; 34 | 35 | export const ROLES = ['pawn', 'knight', 'bishop', 'rook', 'queen', 'king'] as const; 36 | 37 | export type Role = (typeof ROLES)[number]; 38 | 39 | /** 40 | * Indexable by `pawn`, `knight`, `bishop`, `rook`, `queen`, and `king`. 41 | */ 42 | export type ByRole = { 43 | [role in Role]: T; 44 | }; 45 | 46 | export const CASTLING_SIDES = ['a', 'h'] as const; 47 | 48 | export type CastlingSide = (typeof CASTLING_SIDES)[number]; 49 | 50 | /** 51 | * Indexable by `a` and `h`. 52 | */ 53 | export type ByCastlingSide = { 54 | [side in CastlingSide]: T; 55 | }; 56 | 57 | export interface Piece { 58 | role: Role; 59 | color: Color; 60 | promoted?: boolean; 61 | } 62 | 63 | export interface NormalMove { 64 | from: Square; 65 | to: Square; 66 | promotion?: Role; 67 | } 68 | 69 | export interface DropMove { 70 | role: Role; 71 | to: Square; 72 | } 73 | 74 | export type Move = NormalMove | DropMove; 75 | 76 | export const isDrop = (v: Move): v is DropMove => 'role' in v; 77 | 78 | export const isNormal = (v: Move): v is NormalMove => 'from' in v; 79 | 80 | export const RULES = [ 81 | 'chess', 82 | 'antichess', 83 | 'kingofthehill', 84 | '3check', 85 | 'atomic', 86 | 'horde', 87 | 'racingkings', 88 | 'crazyhouse', 89 | ] as const; 90 | 91 | export type Rules = (typeof RULES)[number]; 92 | 93 | export interface Outcome { 94 | winner: Color | undefined; 95 | } 96 | -------------------------------------------------------------------------------- /src/util.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@jest/globals'; 2 | import { makeUci, parseUci } from './util.js'; 3 | 4 | test('parse uci', () => { 5 | expect(parseUci('a1a2')).toEqual({ from: 0, to: 8 }); 6 | expect(parseUci('h7h8k')).toEqual({ from: 55, to: 63, promotion: 'king' }); 7 | expect(parseUci('P@h1')).toEqual({ role: 'pawn', to: 7 }); 8 | }); 9 | 10 | test('make uci', () => { 11 | expect(makeUci({ role: 'queen', to: 1 })).toBe('Q@b1'); 12 | expect(makeUci({ from: 2, to: 3 })).toBe('c1d1'); 13 | expect(makeUci({ from: 0, to: 0, promotion: 'pawn' })).toBe('a1a1p'); 14 | }); 15 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CastlingSide, 3 | Color, 4 | FILE_NAMES, 5 | isDrop, 6 | isNormal, 7 | type LowerCaseRoleChar, 8 | Move, 9 | RANK_NAMES, 10 | Role, 11 | type RoleChar, 12 | Square, 13 | SquareName, 14 | } from './types.js'; 15 | 16 | export const defined = (v: A | undefined): v is A => v !== undefined; 17 | 18 | export const opposite = (color: Color): Color => (color === 'white' ? 'black' : 'white'); 19 | 20 | export const squareRank = (square: Square): number => square >> 3; 21 | 22 | export const squareFile = (square: Square): number => square & 0x7; 23 | 24 | export const squareFromCoords = (file: number, rank: number): Square | undefined => 25 | 0 <= file && file < 8 && 0 <= rank && rank < 8 ? file + 8 * rank : undefined; 26 | 27 | export const roleToChar = (role: Role): LowerCaseRoleChar => { 28 | switch (role) { 29 | case 'pawn': 30 | return 'p'; 31 | case 'knight': 32 | return 'n'; 33 | case 'bishop': 34 | return 'b'; 35 | case 'rook': 36 | return 'r'; 37 | case 'queen': 38 | return 'q'; 39 | case 'king': 40 | return 'k'; 41 | } 42 | }; 43 | 44 | export function charToRole(ch: RoleChar): Role; 45 | export function charToRole(ch: string): Role | undefined; 46 | export function charToRole(ch: string): Role | undefined { 47 | switch (ch.toLowerCase()) { 48 | case 'p': 49 | return 'pawn'; 50 | case 'n': 51 | return 'knight'; 52 | case 'b': 53 | return 'bishop'; 54 | case 'r': 55 | return 'rook'; 56 | case 'q': 57 | return 'queen'; 58 | case 'k': 59 | return 'king'; 60 | default: 61 | return; 62 | } 63 | } 64 | 65 | export function parseSquare(str: SquareName): Square; 66 | export function parseSquare(str: string): Square | undefined; 67 | export function parseSquare(str: string): Square | undefined { 68 | if (str.length !== 2) return; 69 | return squareFromCoords(str.charCodeAt(0) - 'a'.charCodeAt(0), str.charCodeAt(1) - '1'.charCodeAt(0)); 70 | } 71 | 72 | export const makeSquare = (square: Square): SquareName => 73 | (FILE_NAMES[squareFile(square)] + RANK_NAMES[squareRank(square)]) as SquareName; 74 | 75 | export const parseUci = (str: string): Move | undefined => { 76 | if (str[1] === '@' && str.length === 4) { 77 | const role = charToRole(str[0]); 78 | const to = parseSquare(str.slice(2)); 79 | if (role && defined(to)) return { role, to }; 80 | } else if (str.length === 4 || str.length === 5) { 81 | const from = parseSquare(str.slice(0, 2)); 82 | const to = parseSquare(str.slice(2, 4)); 83 | let promotion: Role | undefined; 84 | if (str.length === 5) { 85 | promotion = charToRole(str[4]); 86 | if (!promotion) return; 87 | } 88 | if (defined(from) && defined(to)) return { from, to, promotion }; 89 | } 90 | return; 91 | }; 92 | 93 | export const moveEquals = (left: Move, right: Move): boolean => { 94 | if (left.to !== right.to) return false; 95 | if (isDrop(left)) return isDrop(right) && left.role === right.role; 96 | else return isNormal(right) && left.from === right.from && left.promotion === right.promotion; 97 | }; 98 | 99 | /** 100 | * Converts a move to UCI notation, like `g1f3` for a normal move, 101 | * `a7a8q` for promotion to a queen, and `Q@f7` for a Crazyhouse drop. 102 | */ 103 | export const makeUci = (move: Move): string => 104 | isDrop(move) 105 | ? `${roleToChar(move.role).toUpperCase()}@${makeSquare(move.to)}` 106 | : makeSquare(move.from) + makeSquare(move.to) + (move.promotion ? roleToChar(move.promotion) : ''); 107 | 108 | export const kingCastlesTo = (color: Color, side: CastlingSide): Square => 109 | color === 'white' ? (side === 'a' ? 2 : 6) : side === 'a' ? 58 : 62; 110 | 111 | export const rookCastlesTo = (color: Color, side: CastlingSide): Square => 112 | color === 'white' ? (side === 'a' ? 3 : 5) : side === 'a' ? 59 : 61; 113 | -------------------------------------------------------------------------------- /src/variant.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@jest/globals'; 2 | import { perft } from './debug.js'; 3 | import { makeFen, parseFen } from './fen.js'; 4 | import { makeSanAndPlay, parseSan } from './san.js'; 5 | import { RULES, Rules } from './types.js'; 6 | import { parseUci } from './util.js'; 7 | import { Atomic, defaultPosition, isStandardMaterial, setupPosition } from './variant.js'; 8 | 9 | const skip = 0; 10 | 11 | const variantPerfts: [Rules, string, string, number, number, number][] = [ 12 | ['racingkings', 'racingkings-start', '8/8/8/8/8/8/krbnNBRK/qrbnNBRQ w - -', 21, 421, 11264], 13 | ['racingkings', 'occupied-goal', '4brn1/2K2k2/8/8/8/8/8/8 w - -', 6, 33, 178], 14 | 15 | ['crazyhouse', 'zh-all-drop-types', '2k5/8/8/8/8/8/8/4K3[QRBNPqrbnp] w - -', 301, 75353, skip], 16 | ['crazyhouse', 'zh-drops', '2k5/8/8/8/8/8/8/4K3[Qn] w - -', 67, 3083, 88634], 17 | [ 18 | 'crazyhouse', 19 | 'zh-middlegame', 20 | 'r1bqk2r/pppp1ppp/2n1p3/4P3/1b1Pn3/2NB1N2/PPP2PPP/R1BQK2R[] b KQkq -', 21 | 42, 22 | 1347, 23 | 58057, 24 | ], 25 | ['crazyhouse', 'zh-promoted', '4k3/1Q~6/8/8/4b3/8/Kpp5/8/ b - -', 20, 360, 5445], 26 | 27 | ['horde', 'horde-start', 'rnbqkbnr/pppppppp/8/1PP2PP1/PPPPPPPP/PPPPPPPP/PPPPPPPP/PPPPPPPP w kq -', 8, 128, 1274], 28 | ['horde', 'horde-open-flank', '4k3/pp4q1/3P2p1/8/P3PP2/PPP2r2/PPP5/PPPP4 b - -', 30, 241, 6633], 29 | ['horde', 'horde-en-passant', 'k7/5p2/4p2P/3p2P1/2p2P2/1p2P2P/p2P2P1/2P2P2 w - -', 13, 172, 2205], 30 | 31 | ['atomic', 'atomic-start', 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq -', 20, 400, 8902], 32 | ['atomic', 'programfox-1', 'rn2kb1r/1pp1p2p/p2q1pp1/3P4/2P3b1/4PN2/PP3PPP/R2QKB1R b KQkq -', 40, 1238, 45237], 33 | ['atomic', 'programfox-2', 'rn1qkb1r/p5pp/2p5/3p4/N3P3/5P2/PPP4P/R1BQK3 w Qkq -', 28, 833, 23353], 34 | ['atomic', 'atomic960-castle-1', '8/8/8/8/8/8/2k5/rR4KR w KQ -', 18, 180, 4364], 35 | ['atomic', 'atomic960-castle-2', 'r3k1rR/5K2/8/8/8/8/8/8 b kq -', 25, 282, 6753], 36 | ['atomic', 'atomic960-castle-3', 'Rr2k1rR/3K4/3p4/8/8/8/7P/8 w kq -', 21, 465, 10631], 37 | ['atomic', 'shakmaty-bench', 'rn2kb1r/1pp1p2p/p2q1pp1/3P4/2P3b1/4PN2/PP3PPP/R2QKB1R b KQkq -', 40, 1238, 45237], 38 | ['atomic', 'near-king-explosion', 'rnbqk2r/pp1p2pp/2p3Nn/5p2/1b2P1PP/8/PPP2P2/R1BQKB1R w KQkq -', 5, 132, 4973], 39 | 40 | ['antichess', 'antichess-start', 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - -', 20, 400, 8067], 41 | ['antichess', 'a-pawn-vs-b-pawn', '8/1p6/8/8/8/8/P7/8 w - -', 2, 4, 4], 42 | ['antichess', 'a-pawn-vs-c-pawn', '8/2p5/8/8/8/8/P7/8 w - -', 2, 4, 4], 43 | 44 | ['3check', 'kiwipete', 'r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 1+1', 48, 2039, 97848], 45 | ['3check', 'castling', 'r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 1+1', 26, 562, 13410], 46 | ]; 47 | 48 | test.each(variantPerfts)('variant perft: %s (%s): %s', (rules, name, fen, d1, d2, d3) => { 49 | const pos = setupPosition(rules, parseFen(fen).unwrap()).unwrap(); 50 | expect(perft(pos, 1, false)).toBe(d1); 51 | if (d2) expect(perft(pos, 2, false)).toBe(d2); 52 | if (d3) expect(perft(pos, 3, false)).toBe(d3); 53 | }); 54 | 55 | const falseNegative = false; 56 | 57 | const insufficientMaterial: [Rules, string, boolean, boolean][] = [ 58 | ['atomic', '8/3k4/8/8/2N5/8/3K4/8 b - -', true, true], 59 | ['atomic', '8/4rk2/8/8/8/8/3K4/8 w - -', true, true], 60 | ['atomic', '8/4qk2/8/8/8/8/3K4/8 w - -', true, false], 61 | ['atomic', '8/1k6/8/2n5/8/3NK3/8/8 b - -', false, false], 62 | ['atomic', '8/4bk2/8/8/8/8/3KB3/8 w - -', true, true], 63 | ['atomic', '4b3/5k2/8/8/8/8/3KB3/8 w - -', false, false], 64 | ['atomic', '3Q4/5kKB/8/8/8/8/8/8 b - -', false, true], 65 | ['atomic', '8/5k2/8/8/8/8/5K2/4bb2 w - -', true, false], 66 | ['atomic', '8/5k2/8/8/8/8/5K2/4nb2 w - -', true, false], 67 | 68 | ['antichess', '8/4bk2/8/8/8/8/3KB3/8 w - -', false, false], 69 | ['antichess', '4b3/5k2/8/8/8/8/3KB3/8 w - -', false, false], 70 | ['antichess', '8/8/8/6b1/8/3B4/4B3/5B2 w - -', true, true], 71 | ['antichess', '8/8/5b2/8/8/3B4/3B4/8 w - -', true, false], 72 | ['antichess', '8/5p2/5P2/8/3B4/1bB5/8/8 b - -', falseNegative, falseNegative], 73 | ['antichess', '8/8/8/1n2N3/8/8/8/8 w - - 0 32', true, false], 74 | ['antichess', '8/3N4/8/1n6/8/8/8/8 b - - 1 32', true, false], 75 | ['antichess', '6n1/8/8/4N3/8/8/8/8 b - - 0 27', false, true], 76 | ['antichess', '8/8/5n2/4N3/8/8/8/8 w - - 1 28', false, true], 77 | ['antichess', '8/3n4/8/8/8/8/8/8 w - - 0 29', false, true], 78 | 79 | ['kingofthehill', '8/5k2/8/8/8/8/3K4/8 w - -', false, false], 80 | 81 | ['racingkings', '8/5k2/8/8/8/8/3K4/8 w - -', false, false], 82 | 83 | ['3check', '8/5k2/8/8/8/8/3K4/8 w - - 3+3', true, true], 84 | ['3check', '8/5k2/8/8/8/8/3K2N1/8 w - - 3+3', false, true], 85 | 86 | ['crazyhouse', '8/5k2/8/8/8/8/3K2N1/8[] w - -', true, true], 87 | ['crazyhouse', '8/5k2/8/8/8/5B2/3KB3/8[] w - -', false, false], 88 | 89 | ['horde', '8/5k2/8/8/8/4NN2/8/8 w - - 0 1', true, false], 90 | ['horde', '8/8/8/2B5/p7/kp6/pq6/8 b - - 0 1', false, false], 91 | ['horde', '8/8/8/2B5/r7/kn6/nr6/8 b - - 0 1', true, false], 92 | ['horde', '8/8/1N6/rb6/kr6/qn6/8/8 b - - 0 1', false, false], 93 | ['horde', '8/8/1N6/qq6/kq6/nq6/8/8 b - - 0 1', true, false], 94 | ['horde', '8/P1P5/8/8/8/8/brqqn3/k7 b - - 0 1', false, false], 95 | ['horde', '8/1b5r/1P6/1Pk3q1/1PP5/r1P5/P1P5/2P5 b - - 0 52', false, false], 96 | ]; 97 | 98 | test.each(insufficientMaterial)('%s insufficient material: %s', (rules, fen, white, black) => { 99 | const pos = setupPosition(rules, parseFen(fen).unwrap()).unwrap(); 100 | expect(pos.hasInsufficientMaterial('white')).toBe(white); 101 | expect(pos.hasInsufficientMaterial('black')).toBe(black); 102 | }); 103 | 104 | test('king of the hill not over', () => { 105 | const pos = setupPosition( 106 | 'kingofthehill', 107 | parseFen('rnbqkbnr/pppppppp/8/1Q6/8/8/PPPPPPPP/RNB1KBNR w KQkq - 0 1').unwrap(), 108 | ).unwrap(); 109 | expect(pos.isInsufficientMaterial()).toBe(false); 110 | expect(pos.isCheck()).toBe(false); 111 | expect(pos.isVariantEnd()).toBe(false); 112 | expect(pos.variantOutcome()).toBeUndefined(); 113 | expect(pos.outcome()).toBeUndefined(); 114 | expect(pos.isEnd()).toBe(false); 115 | }); 116 | 117 | test('racing kings end', () => { 118 | // Both players reached the backrank. 119 | const draw = setupPosition( 120 | 'racingkings', 121 | parseFen('kr3NK1/1q2R3/8/8/8/5n2/2N5/1rb2B1R w - - 11 14').unwrap(), 122 | ).unwrap(); 123 | expect(draw.isEnd()).toBe(true); 124 | expect(draw.outcome()).toStrictEqual({ winner: undefined }); 125 | 126 | // White to move is lost because black reached the backrank. 127 | const black = setupPosition('racingkings', parseFen('1k6/6K1/8/8/8/8/8/8 w - - 0 1').unwrap()).unwrap(); 128 | expect(black.isEnd()).toBe(true); 129 | expect(black.outcome()).toStrictEqual({ winner: 'black' }); 130 | 131 | // Black is given a chance to catch up. 132 | const pos = setupPosition('racingkings', parseFen('1K6/7k/8/8/8/8/8/8 b - - 0 1').unwrap()).unwrap(); 133 | expect(pos.isEnd()).toBe(false); 134 | expect(pos.outcome()).toBeUndefined(); 135 | 136 | // Black near backrank but cannot move there. 137 | const white = setupPosition('racingkings', parseFen('2KR4/k7/2Q5/4q3/8/8/8/2N5 b - - 0 1').unwrap()).unwrap(); 138 | expect(white.isEnd()).toBe(true); 139 | expect(white.outcome()).toStrictEqual({ winner: 'white' }); 140 | }); 141 | 142 | test('atomic king exploded', () => { 143 | const pos1 = setupPosition( 144 | 'atomic', 145 | parseFen('r4b1r/ppp1pppp/7n/8/8/8/PPPPPPPP/RNBQKB1R b KQ - 0 3').unwrap(), 146 | ).unwrap(); 147 | expect(pos1.isEnd()).toBe(true); 148 | expect(pos1.isVariantEnd()).toBe(true); 149 | expect(pos1.outcome()).toStrictEqual({ winner: 'white' }); 150 | 151 | const pos2 = setupPosition( 152 | 'atomic', 153 | parseFen('rn5r/pp4pp/2p3Nn/5p2/1b2P1PP/8/PPP2P2/R1B1KB1R b KQ - 0 9').unwrap(), 154 | ).unwrap(); 155 | expect(pos2.isEnd()).toBe(true); 156 | expect(pos2.isVariantEnd()).toBe(true); 157 | expect(pos2.outcome()).toStrictEqual({ winner: 'white' }); 158 | }); 159 | 160 | test('3check remaining checks', () => { 161 | const pos = setupPosition( 162 | '3check', 163 | parseFen('rnbqkbnr/ppp1pppp/3p4/8/8/4P3/PPPP1PPP/RNBQKBNR w KQkq - 3+3 0 2').unwrap(), 164 | ).unwrap(); 165 | pos.play(parseUci('f1b5')!); 166 | expect(makeFen(pos.toSetup())).toBe('rnbqkbnr/ppp1pppp/3p4/1B6/8/4P3/PPPP1PPP/RNBQK1NR b KQkq - 2+3 1 2'); 167 | }); 168 | 169 | test('antichess en passant', () => { 170 | const pos = setupPosition( 171 | 'antichess', 172 | parseFen('r1bqkbn1/p1ppp3/2n4p/6p1/1Pp5/4P3/P2P1PP1/R1B1K3 b - b3 0 11').unwrap(), 173 | ).unwrap(); 174 | const move = parseUci('c4b3')!; 175 | expect(pos.isLegal(move)).toBe(true); 176 | const san = parseSan(pos, 'cxb3'); 177 | expect(move).toEqual(san); 178 | }); 179 | 180 | test('atomic rooks after explosion', () => { 181 | const pos = Atomic.default(); 182 | for ( 183 | const san 184 | of 'e4 d5 d4 e6 Nc3 b5 Bg5 f6 Bh6 Ba3 Bxg7 h5 bxa3 c5 Qc1 Qe7 Qh6 Qg7 Qh8+ Qxh8 Rb1 cxd4 Bxb5 Nd7 Rb7 Kf8 Rxd7 Rb8 Ne2 Rb1+ Nc1 d4 O-O' 185 | .split( 186 | ' ', 187 | ) 188 | ) { 189 | expect(makeSanAndPlay(pos, parseSan(pos, san)!)).toEqual(san); 190 | } 191 | }); 192 | 193 | test.each(RULES)('%s standard material', rules => { 194 | expect(isStandardMaterial(defaultPosition(rules))).toBe(true); 195 | }); 196 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "strictPropertyInitialization": false, 5 | "noEmitOnError": true, 6 | "noImplicitAny": true, 7 | "noImplicitReturns": true, 8 | "noImplicitThis": true, 9 | "moduleResolution": "node", 10 | "target": "ES2018", 11 | "lib": ["ES2018"], 12 | "module": "esnext", 13 | "esModuleInterop": true, 14 | "outDir": "dist/esm", 15 | "declaration": true, 16 | "sourceMap": true 17 | }, 18 | "include": ["src"] 19 | } 20 | --------------------------------------------------------------------------------