diff --git a/docs/fen.md b/docs/fen.md new file mode 100644 index 0000000..6a4f2ff --- /dev/null +++ b/docs/fen.md @@ -0,0 +1,24 @@ +# Fen + +Converts between [FEN](https://en.wikipedia.org/wiki/Forsyth%E2%80%93Edwards_Notation) strings and `Position` objects. +Both methods are static and need no `Chess` instance. + +### `parse(string $fen, ?Board $board = null): Position` + +Parses a FEN string. Throws `\InvalidArgumentException` if the string is invalid. +If `$board` is given, it is cleared and reused. +Only the first king of each color is placed: extra kings are dropped. + +### `serialize(Position $position): string` + +Gets the FEN string of a position. + +```php +use PChess\Chess\Fen; + +$position = Fen::parse('rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1'); +echo Fen::serialize($position); +``` + +Validation is delegated to `Validation::validateFen()`, which returns an error code (`0` means valid), +and `Validation::getError()`, which returns the message for a code. diff --git a/docs/index.md b/docs/index.md index c3d4dab..7ab5f5d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,6 +4,7 @@ * [Board](board.md) * [Piece](piece.md) * [Move](move.md) +* [Fen](fen.md) * [History](history.md) * [HtmlOutput](output_html.md) * [ImagineOutput](output_imagine.md) diff --git a/src/Chess.php b/src/Chess.php index c44d335..10eda66 100644 --- a/src/Chess.php +++ b/src/Chess.php @@ -27,11 +27,10 @@ public function __construct( ?History $history = null, ?ZobristHasher $zobristHasher = null, ) { - $this->board = new Board(); $this->zobristHasher = $zobristHasher ?? new ZobristHasher(); - if (null !== $error = $this->load($fen ?? Board::DEFAULT_POSITION)) { - throw new \InvalidArgumentException(\sprintf('Invalid fen: %s. Error: %s', $fen, $error)); - } + $this->position = Fen::parse($fen ?? Board::DEFAULT_POSITION); + $this->board = $this->position->board; + $this->refreshBoardHash(); $this->history = $history ?? new History(); } @@ -49,15 +48,6 @@ public function __isset(string $name): bool return $name === 'turn'; } - protected function clear(): void - { - $this->position = Position::empty($this->board); - $this->generateMovesCache = []; - $this->sanMoveCache = []; - - $this->refreshBoardHash(); - } - /** * @param array $moves */ @@ -74,109 +64,9 @@ private static function addMove(string $turn, Board $board, array &$moves, int $ } } - protected function load(string $fen): ?string - { - $result = Validation::validateFen($fen); - if ($result > 0) { - return Validation::getError($result); - } - $tokens = \explode(' ', $fen); - $this->clear(); - - // position - $position = $tokens[0]; - $square = 0; - for ($i = 0, $iMax = \strlen($position); $i < $iMax; ++$i) { - $piece = $position[$i]; - if ($piece === '/') { - $square += 8; - } elseif (\ctype_digit($piece)) { - $square += (int) $piece; - } else { - $color = (\ord($piece) < \ord('a')) ? Piece::WHITE : Piece::BLACK; - $this->put(new Piece(\strtolower($piece), $color), Board::algebraic($square)); - ++$square; - } - } - - // turn - $this->position->turn = $tokens[1]; - - // castling options - if (\str_contains($tokens[2], 'K')) { - $this->position->castling[Piece::WHITE] |= Move::BITS['KSIDE_CASTLE']; - } - if (\str_contains($tokens[2], 'Q')) { - $this->position->castling[Piece::WHITE] |= Move::BITS['QSIDE_CASTLE']; - } - if (\str_contains($tokens[2], 'k')) { - $this->position->castling[Piece::BLACK] |= Move::BITS['KSIDE_CASTLE']; - } - if (\str_contains($tokens[2], 'q')) { - $this->position->castling[Piece::BLACK] |= Move::BITS['QSIDE_CASTLE']; - } - - // ep square - $this->position->epSquare = ($tokens[3] === '-') ? null : Board::SQUARES[$tokens[3]]; - - // half moves - $this->position->halfMoves = (int) $tokens[4]; - - // move number - $this->position->moveNumber = (int) $tokens[5]; - - $this->refreshBoardHash(); - - return null; - } - public function fen(): string { - $empty = 0; - $fen = ''; - for ($i = Board::SQUARES['a8']; $i <= Board::SQUARES['h1']; ++$i) { - if (null === $piece = $this->board[$i]) { - ++$empty; - } else { - if ($empty > 0) { - $fen .= $empty; - $empty = 0; - } - $fen .= $piece->toAscii(); - } - - if ((($i + 1) & 0x88) > 0) { - if ($empty > 0) { - $fen .= $empty; - } - if ($i !== Board::SQUARES['h1']) { - $fen .= '/'; - } - $empty = 0; - $i += 8; - } - } - - $cFlags = ''; - if (($this->position->castling[Piece::WHITE] & Move::BITS['KSIDE_CASTLE']) > 0) { - $cFlags .= 'K'; - } - if (($this->position->castling[Piece::WHITE] & Move::BITS['QSIDE_CASTLE']) > 0) { - $cFlags .= 'Q'; - } - if (($this->position->castling[Piece::BLACK] & Move::BITS['KSIDE_CASTLE']) > 0) { - $cFlags .= 'k'; - } - if (($this->position->castling[Piece::BLACK] & Move::BITS['QSIDE_CASTLE']) > 0) { - $cFlags .= 'q'; - } - if ($cFlags === '') { - $cFlags = '-'; - } - - $epFlags = $this->position->epSquare === null ? '-' : Board::algebraic($this->position->epSquare); - - return \implode(' ', [$fen, $this->position->turn, $cFlags, $epFlags, $this->position->halfMoves, $this->position->moveNumber]); + return Fen::serialize($this->position); } public function remove(string $square): bool diff --git a/src/Fen.php b/src/Fen.php new file mode 100644 index 0000000..fe4ef72 --- /dev/null +++ b/src/Fen.php @@ -0,0 +1,128 @@ + 0) { + throw new \InvalidArgumentException(\sprintf('Invalid fen: %s. Error: %s', $fen, Validation::getError($result))); + } + $tokens = \explode(' ', $fen); + $position = Position::empty($board); + + // pieces + $placement = $tokens[0]; + $square = 0; + for ($i = 0, $iMax = \strlen($placement); $i < $iMax; ++$i) { + $symbol = $placement[$i]; + if ($symbol === '/') { + $square += 8; + } elseif (\ctype_digit($symbol)) { + $square += (int) $symbol; + } else { + $color = (\ord($symbol) < \ord('a')) ? Piece::WHITE : Piece::BLACK; + $piece = new Piece(\strtolower($symbol), $color); + // only one king per color: extra ones are dropped + if ($piece->isKing()) { + if ($position->kings[$color] === null) { + $position->board[$square] = $piece; + $position->kings[$color] = $square; + } + } else { + $position->board[$square] = $piece; + } + ++$square; + } + } + + // turn + $position->turn = $tokens[1]; + + // castling options + if (\str_contains($tokens[2], 'K')) { + $position->castling[Piece::WHITE] |= Move::BITS['KSIDE_CASTLE']; + } + if (\str_contains($tokens[2], 'Q')) { + $position->castling[Piece::WHITE] |= Move::BITS['QSIDE_CASTLE']; + } + if (\str_contains($tokens[2], 'k')) { + $position->castling[Piece::BLACK] |= Move::BITS['KSIDE_CASTLE']; + } + if (\str_contains($tokens[2], 'q')) { + $position->castling[Piece::BLACK] |= Move::BITS['QSIDE_CASTLE']; + } + + // ep square + $position->epSquare = ($tokens[3] === '-') ? null : Board::SQUARES[$tokens[3]]; + + // half moves + $position->halfMoves = (int) $tokens[4]; + + // move number + $position->moveNumber = (int) $tokens[5]; + + return $position; + } + + public static function serialize(Position $position): string + { + $empty = 0; + $fen = ''; + for ($i = Board::SQUARES['a8']; $i <= Board::SQUARES['h1']; ++$i) { + if (null === $piece = $position->board[$i]) { + ++$empty; + } else { + if ($empty > 0) { + $fen .= $empty; + $empty = 0; + } + $fen .= $piece->toAscii(); + } + + if ((($i + 1) & 0x88) > 0) { + if ($empty > 0) { + $fen .= $empty; + } + if ($i !== Board::SQUARES['h1']) { + $fen .= '/'; + } + $empty = 0; + $i += 8; + } + } + + $cFlags = ''; + if (($position->castling[Piece::WHITE] & Move::BITS['KSIDE_CASTLE']) > 0) { + $cFlags .= 'K'; + } + if (($position->castling[Piece::WHITE] & Move::BITS['QSIDE_CASTLE']) > 0) { + $cFlags .= 'Q'; + } + if (($position->castling[Piece::BLACK] & Move::BITS['KSIDE_CASTLE']) > 0) { + $cFlags .= 'k'; + } + if (($position->castling[Piece::BLACK] & Move::BITS['QSIDE_CASTLE']) > 0) { + $cFlags .= 'q'; + } + if ($cFlags === '') { + $cFlags = '-'; + } + + $epFlags = $position->epSquare === null ? '-' : Board::algebraic($position->epSquare); + + return \implode(' ', [$fen, $position->turn, $cFlags, $epFlags, $position->halfMoves, $position->moveNumber]); + } +} diff --git a/tests/FenTest.php b/tests/FenTest.php index ebf7bbf..d3ccef3 100644 --- a/tests/FenTest.php +++ b/tests/FenTest.php @@ -5,6 +5,9 @@ namespace PChess\Chess\Test; use PChess\Chess\Board; +use PChess\Chess\Fen; +use PChess\Chess\Move; +use PChess\Chess\Piece; use PChess\Chess\Validation; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -23,6 +26,51 @@ public function testGetErrorString(): void self::assertEquals('Invalid code.', Validation::getError(42)); } + #[DataProvider('provider')] + public function testParseAndSerializeRoundTrip(string $fen, int $code): void + { + if ($code > 0) { + $this->expectException(\InvalidArgumentException::class); + } + self::assertSame($fen, Fen::serialize(Fen::parse($fen))); + } + + public function testParse(): void + { + $position = Fen::parse('rnbqkbnr/pp1ppppp/8/2p5/4P3/8/PPPP1PPP/RNBQKBNR w Kq c6 0 2'); + self::assertSame(Piece::WHITE, $position->turn); + self::assertSame([Piece::WHITE => Board::SQUARES['e1'], Piece::BLACK => Board::SQUARES['e8']], $position->kings); + self::assertSame([Piece::WHITE => Move::BITS['KSIDE_CASTLE'], Piece::BLACK => Move::BITS['QSIDE_CASTLE']], $position->castling); + self::assertSame(Board::SQUARES['c6'], $position->epSquare); + self::assertSame(0, $position->halfMoves); + self::assertSame(2, $position->moveNumber); + self::assertEquals(new Piece(Piece::PAWN, Piece::BLACK), $position->board[Board::SQUARES['c5']]); + self::assertNull($position->board[Board::SQUARES['c7']]); + } + + public function testParseDropsExtraKings(): void + { + $position = Fen::parse('k6k/8/8/8/8/8/8/K6K w - - 0 1'); + self::assertSame([Piece::WHITE => Board::SQUARES['a1'], Piece::BLACK => Board::SQUARES['a8']], $position->kings); + self::assertSame('k7/8/8/8/8/8/8/K7 w - - 0 1', Fen::serialize($position)); + } + + public function testParseReusesBoard(): void + { + $board = new Board(); + $board[Board::SQUARES['d4']] = new Piece(Piece::QUEEN, Piece::WHITE); + $position = Fen::parse('4k3/8/8/8/8/8/8/4K3 w - - 0 1', $board); + self::assertSame($board, $position->board); + self::assertNull($board[Board::SQUARES['d4']]); + } + + public function testParseInvalid(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid fen: 8/8/8/8/8/8/8/8 w 1. Error: FEN string must contain six space-delimited fields.'); + Fen::parse('8/8/8/8/8/8/8/8 w 1'); + } + /** * @return array> */