Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions docs/fen.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
118 changes: 4 additions & 114 deletions src/Chess.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand All @@ -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<Move> $moves
*/
Expand All @@ -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
Expand Down
128 changes: 128 additions & 0 deletions src/Fen.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
<?php

declare(strict_types=1);

namespace PChess\Chess;

/**
* Converts between FEN strings and Position objects.
*/
final class Fen
{
/**
* @param ?Board $board if given, it is cleared and reused
*
* @throws \InvalidArgumentException
*/
public static function parse(string $fen, ?Board $board = null): Position
{
$result = Validation::validateFen($fen);
if ($result > 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]);
}
}
48 changes: 48 additions & 0 deletions tests/FenTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<int|string, array<int, int|string>>
*/
Expand Down
Loading