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
9 changes: 5 additions & 4 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
*.cache
composer.lock
resources/
vendor/
/*.cache
/build/
/composer.lock
/resources/
/vendor/
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ There is still a lot to do in this topic.
| 3 | 340.375μs | replace fen with json_encode in generateMoves |
| 4 | 333.145μs | add boardHash calculation on make/undo move |
| 5 | 25.917μs | :fire: add cache for moveToSAN method |
| 6 | 14.908μs | :zap: replace json_encode board hash with incremental Zobrist hashing |

## Other documentation

Expand Down
73 changes: 66 additions & 7 deletions src/Chess.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,17 @@ class Chess
protected array $generateMovesCache = [];

protected string $boardHash = '';
protected ZobristHasher $zobristHasher;
/** @var array<string, string> */
protected array $sanMoveCache = [];

public function __construct(?string $fen = Board::DEFAULT_POSITION, ?History $history = null)
{
public function __construct(
?string $fen = Board::DEFAULT_POSITION,
?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));
}
Expand All @@ -39,7 +44,6 @@ public function __construct(?string $fen = Board::DEFAULT_POSITION, ?History $hi

protected function clear(): void
{
$this->boardHash = \json_encode($this->board, JSON_THROW_ON_ERROR);
$this->kings = [Piece::WHITE => null, Piece::BLACK => null];
$this->turn = Piece::WHITE;
$this->castling = [Piece::WHITE => 0, Piece::BLACK => 0];
Expand All @@ -52,6 +56,8 @@ protected function clear(): void
foreach (Board::SQUARES as $square) {
$this->board[$square] = null;
}

$this->refreshBoardHash();
}

/**
Expand Down Expand Up @@ -121,7 +127,7 @@ protected function load(string $fen): ?string
// move number
$this->moveNumber = (int) $tokens[5];

$this->boardHash = \json_encode($this->board, JSON_THROW_ON_ERROR);
$this->refreshBoardHash();

return null;
}
Expand Down Expand Up @@ -229,6 +235,10 @@ protected function makeMove(Move $move): void
$us = $this->turn;
$them = self::swapColor($us);
$historyKey = $this->recordMove($move);
$previousEpSquare = $this->epSquare;
$previousUsCastling = $this->castling[$us];
$previousThemCastling = $this->castling[$them];
$hashablePreviousEpSquare = $this->hashableEpSquare($previousEpSquare, $us);

$this->board[$move->toSquare] = $this->board[$move->fromSquare];
$this->board[$move->fromSquare] = null;
Expand Down Expand Up @@ -311,7 +321,19 @@ protected function makeMove(Move $move): void
}
$this->turn = $them;

$this->boardHash = \json_encode($this->board, JSON_THROW_ON_ERROR);
$hashableCurrentEpSquare = $this->hashableEpSquare($this->epSquare, $this->turn);

$this->boardHash = $this->zobristHasher->applyMove(
$move,
$us,
$them,
$hashablePreviousEpSquare,
$hashableCurrentEpSquare,
$previousUsCastling,
Comment thread
garak marked this conversation as resolved.
$this->castling[$us],
$previousThemCastling,
$this->castling[$them],
);
$this->history->get($historyKey)->position = $this->boardHash;
}

Expand Down Expand Up @@ -379,7 +401,11 @@ protected function undoMove(): ?Move
$this->board[$castlingFrom] = null;
}

$this->boardHash = \json_encode($this->board, JSON_THROW_ON_ERROR);
if ($old->previousPosition === null) {
$this->refreshBoardHash();
} else {
$this->boardHash = $this->zobristHasher->restore($old->previousPosition);
}

return $move;
}
Expand All @@ -399,7 +425,7 @@ public function undo(): ?Move
*/
protected function generateMoves(?int $square = null, bool $legal = true): array
{
$cacheKey = $this->boardHash.$square.($legal ? '1' : '0');
$cacheKey = $this->boardHash.'|'.($square === null ? 'all' : (string) $square).'|'.($legal ? '1' : '0');

// check cache first
if (isset($this->generateMovesCache[$cacheKey])) {
Expand Down Expand Up @@ -901,4 +927,37 @@ public function getHistory(): History
{
return $this->history;
}

private function refreshBoardHash(): void
{
$this->boardHash = $this->zobristHasher->rebuild(
$this->board,
$this->turn,
$this->castling,
$this->hashableEpSquare($this->epSquare, $this->turn),
);
}

/**
* Returns the EP square only when a pawn of the given turn can actually
* capture en passant, so that identical positions always hash the same.
*/
private function hashableEpSquare(?int $epSquare, string $turn): ?int
{
if ($epSquare === null) {
return null;
}

foreach ([2, 3] as $j) {
$from = $epSquare - Piece::PAWN_OFFSETS[$turn][$j];
if (($from & 0x88) === 0) {
$piece = $this->board[$from];
if ($piece !== null && $piece->getType() === Piece::PAWN && $piece->getColor() === $turn) {
return $epSquare;
}
}
}

return null;
}
}
2 changes: 2 additions & 0 deletions src/Entry.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
class Entry implements \Stringable
{
public string $turn;
public ?string $previousPosition;

/**
* @param array<string, ?int> $kings
Expand All @@ -22,6 +23,7 @@ public function __construct(
public int $moveNumber,
) {
$this->turn = $move->turn;
$this->previousPosition = $position;
}

public function __toString(): string
Expand Down
181 changes: 181 additions & 0 deletions src/ZobristHasher.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
<?php

declare(strict_types=1);

namespace PChess\Chess;

final class ZobristHasher
{
private const TURN_ID = 1;
private const EP_OFFSET = 100;
private const CASTLING_OFFSET = 300;
private const PIECE_OFFSET = 1000;

/** @var array<int, string> */
private static array $zobristCache = [];

private string $hash = "\0\0\0\0\0\0\0\0";

/**
* @param array<string, int> $castling
*/
public function rebuild(Board $board, string $turn, array $castling, ?int $epSquare): string
{
$this->hash = "\0\0\0\0\0\0\0\0";
foreach (Board::SQUARES as $square) {
$piece = $board[$square];
if ($piece !== null) {
$this->xorPiece($piece, $square);
}
}

if ($turn === Piece::BLACK) {
$this->hash ^= self::zobristKey(self::TURN_ID);
}

$this->xorCastlingDelta(Piece::WHITE, 0, $castling[Piece::WHITE]);
$this->xorCastlingDelta(Piece::BLACK, 0, $castling[Piece::BLACK]);
$this->xorEpSquare($epSquare);

return $this->toHex();
}

public function applyMove(
Move $move,
string $us,
string $them,
?int $previousEpSquare,
?int $currentEpSquare,
int $previousUsCastling,
int $currentUsCastling,
int $previousThemCastling,
int $currentThemCastling,
): string {
$this->xorPiece($move->piece, $move->fromSquare);
if (($move->flags & Move::BITS['CAPTURE']) > 0) {
\assert(null !== $move->captured);
$this->xorPieceByTypeAndColor($move->captured, $them, $move->toSquare);
} elseif (($move->flags & Move::BITS['EP_CAPTURE']) > 0) {
\assert(null !== $move->captured);
$captureSquare = $move->toSquare + ($us === Piece::BLACK ? -16 : 16);
$this->xorPieceByTypeAndColor($move->captured, $them, $captureSquare);
}

if (($move->flags & Move::BITS['PROMOTION']) > 0) {
\assert(null !== $move->promotion);
$this->xorPieceByTypeAndColor($move->promotion, $us, $move->toSquare);
} else {
$this->xorPiece($move->piece, $move->toSquare);
}

if (($move->flags & Move::BITS['KSIDE_CASTLE']) > 0) {
$castlingTo = $move->toSquare - 1;
$castlingFrom = $move->toSquare + 1;
$this->xorPieceByTypeAndColor(Piece::ROOK, $us, $castlingFrom);
$this->xorPieceByTypeAndColor(Piece::ROOK, $us, $castlingTo);
} elseif (($move->flags & Move::BITS['QSIDE_CASTLE']) > 0) {
$castlingTo = $move->toSquare + 1;
$castlingFrom = $move->toSquare - 2;
$this->xorPieceByTypeAndColor(Piece::ROOK, $us, $castlingFrom);
$this->xorPieceByTypeAndColor(Piece::ROOK, $us, $castlingTo);
}

$this->xorEpSquare($previousEpSquare);
$this->xorEpSquare($currentEpSquare);
$this->xorCastlingDelta($us, $previousUsCastling, $currentUsCastling);
$this->xorCastlingDelta($them, $previousThemCastling, $currentThemCastling);
$this->hash ^= self::zobristKey(self::TURN_ID);

return $this->toHex();
}

public function restore(string $hash): string
{
$binaryHash = \hex2bin($hash);
if ($binaryHash === false || \strlen($binaryHash) !== 8) {
throw new \RuntimeException('Invalid history position hash encoding.');
}

$this->hash = $binaryHash;

return $this->toHex();
}

private function toHex(): string
{
return \bin2hex($this->hash);
}

private static function zobristKey(int $id): string
{
if (isset(self::$zobristCache[$id])) {
return self::$zobristCache[$id];
}

self::$zobristCache[$id] = \substr(\hash('sha256', 'pchess-zobrist-'.$id, true), 0, 8);

return self::$zobristCache[$id];
}

private static function pieceIndex(string $type, string $color): int
{
$colorOffset = $color === Piece::WHITE ? 0 : 6;

$typeIndex = match ($type) {
Piece::PAWN => 0,
Piece::KNIGHT => 1,
Piece::BISHOP => 2,
Piece::ROOK => 3,
Piece::QUEEN => 4,
Piece::KING => 5,
default => throw new \InvalidArgumentException('Invalid piece type: '.$type),
};

return $colorOffset + $typeIndex;
}

private static function castlingKeyId(string $color, int $flag): int
{
$colorOffset = $color === Piece::WHITE ? 0 : 2;
$sideOffset = match ($flag) {
Move::BITS['KSIDE_CASTLE'] => 0,
Move::BITS['QSIDE_CASTLE'] => 1,
default => throw new \InvalidArgumentException('Invalid castling flag: '.$flag),
};

return self::CASTLING_OFFSET + $colorOffset + $sideOffset;
}

private function pieceKeyId(string $type, string $color, int $square): int
{
return self::PIECE_OFFSET + (self::pieceIndex($type, $color) * 128) + $square;
}

private function xorPiece(Piece $piece, int $square): void
{
$this->hash ^= self::zobristKey($this->pieceKeyId($piece->getType(), $piece->getColor(), $square));
}

private function xorPieceByTypeAndColor(string $type, string $color, int $square): void
{
$this->hash ^= self::zobristKey($this->pieceKeyId($type, $color, $square));
}

private function xorEpSquare(?int $epSquare): void
{
if ($epSquare !== null) {
$this->hash ^= self::zobristKey(self::EP_OFFSET + $epSquare);
}
}

private function xorCastlingDelta(string $color, int $before, int $after): void
{
$changed = $before ^ $after;
if (($changed & Move::BITS['KSIDE_CASTLE']) > 0) {
$this->hash ^= self::zobristKey(self::castlingKeyId($color, Move::BITS['KSIDE_CASTLE']));
}
if (($changed & Move::BITS['QSIDE_CASTLE']) > 0) {
$this->hash ^= self::zobristKey(self::castlingKeyId($color, Move::BITS['QSIDE_CASTLE']));
}
}
}
Loading
Loading