From ba9fc502c30db171a79c56347685afa2aade457f Mon Sep 17 00:00:00 2001 From: overflowerror Date: Fri, 5 Jan 2024 00:22:13 +0100 Subject: [PATCH] feat: Add game system function for fetching legal moves in position --- src/Game/Game.php | 163 ++++++++++++++++++++++++++++++++++ src/Game/Move.php | 33 +++++++ src/Game/Pawn.php | 4 + src/Game/Piece.php | 43 ++++++++- src/Game/PieceType.php | 27 ++++++ src/Game/Position.php | 4 + tests/Game/GameTest.php | 188 ++++++++++++++++++++++++++++++++++++++++ 7 files changed, 461 insertions(+), 1 deletion(-) create mode 100644 src/Game/Move.php create mode 100644 src/Game/PieceType.php diff --git a/src/Game/Game.php b/src/Game/Game.php index 3d6a6e7..5b895fe 100644 --- a/src/Game/Game.php +++ b/src/Game/Game.php @@ -21,6 +21,22 @@ class Game { return array_filter($this->pieces, fn($p) => $p->getSide() == $side); } + private function &findPiece(Piece $needle): Piece { + foreach ($this->pieces as &$piece) { + if ($piece->equals($needle)) { + return $piece; + } + } + + throw new \RuntimeException("piece not found: " . $piece); + } + + private function removePiece(Piece $needle): void { + $this->pieces = array_values( + array_filter($this->pieces, fn($p) => !($p->equals($needle))) + ); + } + private function getKing(Side $side): King { if ($side == Side::WHITE) { return $this->whiteKing; @@ -42,6 +58,24 @@ class Game { return $this->getOccupied($this->pieces); } + private function getCaptureable(array $pieces, bool $forPawn): FieldBitMap { + $captureableMap = FieldBitMap::empty(); + foreach ($pieces as $piece) { + $captureableMap = $captureableMap->union($piece->getCaptureableMap($forPawn)); + } + + return $captureableMap; + } + + private function getThreatened(array $pieces, FieldBitMap $occupied): FieldBitMap { + $threatenedMap = FieldBitMap::empty(); + foreach ($pieces as $piece) { + $threatenedMap = $threatenedMap->union($piece->getCaptureMap($occupied)); + } + + return $threatenedMap; + } + private function isInCheck(Side $side, FieldBitMap $allOccupied): bool { $opponentPieces = $this->getPieces($side->getNext()); $king = $this->getKing($side); @@ -65,6 +99,131 @@ class Game { return $this->isInCheck($this->current->getNext(), $allOccupied); } + + public function getLegalMoves(): array { + $ownPieces = $this->getPieces($this->current); + $opponentPieces = $this->getPieces($this->current->getNext()); + + $occupied = $this->getOccupied($ownPieces); + $threatened = $this->getThreatened($opponentPieces, $occupied); + + return $this->getLegalMovesCached( + $ownPieces, + $opponentPieces, + $occupied, + $this->getCaptureable($opponentPieces, false), + $this->getCaptureable($opponentPieces, true), + $threatened, + ); + } + + private function generatePromotionMoves(Move $candidate): array { + $candidates = []; + + foreach (PieceType::getPromotionPieces() as $type) { + $candidate->promoteTo = $type; + $candidates[] = clone $candidate; + } + + return $candidates; + } + + private function findCapturedPiece(Piece $piece, array $opponentPieces, Position $target): ?Piece { + foreach ($opponentPieces as $capture) { + if ($capture->getCaptureableMap($piece instanceof Pawn)->has($target)) { + return $capture; + } + } + + return null; + } + + private function isCapture(Position $target, FieldBitMap $captureableForPawn): bool { + return $captureableForPawn->has($target); + } + + private function getCandidateMovesForPiece( + Piece $piece, + array $opponentPieces, + FieldBitMap $occupied, + FieldBitMap $capturableForNonPawn, + FieldBitMap $captureableForPawn, + FieldBitMap $threatened, + ): array { + $candidates = []; + + $candidateMap = $piece->getMoveCandidateMap( + $occupied, + ($piece instanceof Pawn) ? $captureableForPawn : $capturableForNonPawn, + $threatened + ); + + foreach ($candidateMap->getPositions() as $target) { + $candidate = new Move($piece, $target); + if ($this->isCapture($target, $captureableForPawn)) { + $candidate->captures = $this->findCapturedPiece($piece, $opponentPieces, $target); + } + if ($piece->canPromote($target)) { + $candidates = array_merge($candidates, $this->generatePromotionMoves($candidate)); + } else { + $candidates[] = $candidate; + } + } + + return $candidates; + } + + private function isMoveLegal(Move $move) { + $futureGame = $this->apply($move); + return $futureGame->getGameState() != GameState::ILLEGAL; + } + + private function getLegalMovesCached( + array &$ownPieces, + array &$opponentPieces, + FieldBitMap $occupied, + FieldBitMap $capturableNonPawn, + FieldBitMap $captureablePawn, + FieldBitMap $threatened + ): array { + $candidates = []; + + foreach ($ownPieces as $piece) { + $candidates = array_merge($candidates, $this->getCandidateMovesForPiece( + $piece, + $opponentPieces, + $occupied, + $capturableNonPawn, + $captureablePawn, + $threatened, + )); + } + + return array_values(array_filter($candidates, [$this, "isMoveLegal"])); + } + + public function apply(Move $move): Game { + $game = clone $this; + + if ($move->captures) { + $game->removePiece($move->captures); + } + if ($move->promoteTo) { + $game->removePiece($move->piece); + + $promoted = $move->piece->promote($move->promoteTo); + $promoted->move($move->target); + $game->pieces[] = $promoted; + } else { + $piece = $game->findPiece($move->piece); + $piece->move($move->target); + } + + $game->current = $game->current->getNext(); + + return $game; + } + public function getGameState(): GameState { $allOccupied = $this->getAllOccupied(); @@ -81,6 +240,10 @@ class Game { return GameState::DEFAULT; } + public function __clone(): void { + $this->pieces = array_map(fn($p) => clone $p, $this->pieces); + } + public function visualize(): string { } diff --git a/src/Game/Move.php b/src/Game/Move.php new file mode 100644 index 0000000..d8af85b --- /dev/null +++ b/src/Game/Move.php @@ -0,0 +1,33 @@ +piece = $piece; + $this->target = $target; + $this->captures = $captures; + $this->promoteTo = $promoteTo; + } + + public function equals(Move $move): bool { + return $this->piece->equals($move->piece) && + $this->target->equals($move->target) && + ( + ($this->captures != null && $move->captures != null && $this->captures->equals($move->captures)) || + ($this->captures == null && $move->captures == null) + ) && + $this->promoteTo == $move->promoteTo; + } + + public function __toString(): string { + return $this->piece . " " . + $this->piece->getShort() . ($this->captures ? "x" : "") . $this->target . + ($this->promoteTo ? $this->promoteTo->getShort() : ""); + } +} \ No newline at end of file diff --git a/src/Game/Pawn.php b/src/Game/Pawn.php index cffa858..5efc752 100644 --- a/src/Game/Pawn.php +++ b/src/Game/Pawn.php @@ -68,4 +68,8 @@ class Pawn extends Piece { return $result; } + + public function canPromote(Position $position): bool { + return ($this->side == Side::WHITE) ? ($position->rank == 7) : ($position->rank == 0); + } } \ No newline at end of file diff --git a/src/Game/Piece.php b/src/Game/Piece.php index ff7e08a..c2a4ceb 100644 --- a/src/Game/Piece.php +++ b/src/Game/Piece.php @@ -9,9 +9,10 @@ abstract class Piece { protected bool $wasMovedLast = false; protected ?Position $oldPosition = null; - public function __construct(Position $position, Side $side) { + public function __construct(Position $position, Side $side, bool $hasMoved = false) { $this->position = $position; $this->side = $side; + $this->hasMoved = $hasMoved; } public function tick() { @@ -46,4 +47,44 @@ abstract class Piece { public function __toString() { return $this->getShort() . $this->getPosition(); } + + private static function getClassForType(PieceType $type): string { + switch ($type) { + case PieceType::PAWN: + return Pawn::class; + case PieceType::BISHOP: + return Bishop::class; + case PieceType::KNIGHT: + return Knight::class; + case PieceType::ROOK: + return Rook::class; + case PieceType::QUEEN: + return Queen::class; + case PieceType::KING: + return King::class; + } + + throw new \RuntimeException("unknown piecetype " . $type); + } + + public static function ofType(PieceType $type, Position $position, Side $side): Piece { + return new (self::getClassForType($type))($position, $side); + } + + public function promote(PieceType $type): Piece { + $result = self::ofType($type, $this->position, $this->side); + $result->hasMoved = $this->hasMoved; + $result->wasMovedLast = $this->wasMovedLast; + $result->oldPosition = $this->oldPosition; + return $result; + } + + public function equals(Piece $piece): bool { + return get_class($this) == get_class($piece) && + $this->position->equals($piece->position); + } + + public function canPromote(Position $position): bool { + return false; + } } \ No newline at end of file diff --git a/src/Game/PieceType.php b/src/Game/PieceType.php new file mode 100644 index 0000000..73337f5 --- /dev/null +++ b/src/Game/PieceType.php @@ -0,0 +1,27 @@ + "", + self::BISHOP => "B", + self::KNIGHT => "N", + self::ROOK => "R", + self::QUEEN => "Q", + self::KING => "K", + }; + } +} \ No newline at end of file diff --git a/src/Game/Position.php b/src/Game/Position.php index 423830b..2c97f32 100644 --- a/src/Game/Position.php +++ b/src/Game/Position.php @@ -20,4 +20,8 @@ class Position { public function __toString(): string { return ["a", "b", "c", "d", "e", "f", "g", "h"][$this->file] . ($this->rank + 1); } + + public function equals(Position $position): bool { + return $this->file == $position->file && $this->rank == $position->rank; + } } \ No newline at end of file diff --git a/tests/Game/GameTest.php b/tests/Game/GameTest.php index 69c7e7f..d898ac1 100644 --- a/tests/Game/GameTest.php +++ b/tests/Game/GameTest.php @@ -6,6 +6,26 @@ use PHPUnit\Framework\TestCase; final class GameTest extends TestCase { + protected function assertContainsEqualsOnce(object $needle, array $haystack) { + if (!method_exists($needle, "equals")) { + $this->assertFalse("equals() missing on needle"); + } + + $result = false; + + foreach ($haystack as $item) { + if ($needle->equals($item)) { + if ($result) { + $this->assertFalse("element duplication"); + } else { + $result = true; + } + } + } + + $this->assertTrue($result, "no such element"); + } + public function testGameState_illegal_white() { $subject = new Game( [ @@ -57,4 +77,172 @@ final class GameTest extends TestCase { $this->assertEquals(GameState::CHECK, $subject->getGameState()); } + + public function testLegalMoves_pawnPinnedBecauseOfCheckKingRestrictedByQueenAndPawn() { + $subject = new Game( + [ + new King(new Position(7, 6), Side::BLACK), + new Queen(new Position(1, 6), Side::BLACK), + new Pawn(new Position(2, 6), Side::WHITE), + new King(new Position(3, 6), Side::WHITE), + ], + Side::WHITE + ); + + $legalMoves = $subject->getLegalMoves(); + + $this->assertCount(5, $legalMoves); + + $this->assertContainsEqualsOnce(new Move( + new King(new Position(3, 6), Side::WHITE), + new Position(3, 7), + null, null, + ), $legalMoves); + + $this->assertContainsEqualsOnce(new Move( + new King(new Position(3, 6), Side::WHITE), + new Position(4, 7), + null, null, + ), $legalMoves); + + $this->assertContainsEqualsOnce(new Move( + new King(new Position(3, 6), Side::WHITE), + new Position(4, 6), + null, null, + ), $legalMoves); + + $this->assertContainsEqualsOnce(new Move( + new King(new Position(3, 6), Side::WHITE), + new Position(4, 5), + null, null, + ), $legalMoves); + + $this->assertContainsEqualsOnce(new Move( + new King(new Position(3, 6), Side::WHITE), + new Position(3, 5), + null, null, + ), $legalMoves); + } + + public function testLegalMoves_kingIsBlockedPawnCanPromote() { + $subject = new Game( + [ + new King(new Position(0, 0), Side::BLACK), + new King(new Position(3, 6), Side::WHITE), + new Queen(new Position(1, 2), Side::WHITE), + new Pawn(new Position(7, 1), Side::BLACK, true), + ], + Side::BLACK + ); + + $legalMoves = $subject->getLegalMoves(); + + $this->assertCount(4, $legalMoves); + + $this->assertContainsEqualsOnce(new Move( + new Pawn(new Position(7, 1), Side::BLACK), + new Position(7, 0), + null, PieceType::BISHOP, + ), $legalMoves); + + $this->assertContainsEqualsOnce(new Move( + new Pawn(new Position(7, 1), Side::BLACK), + new Position(7, 0), + null, PieceType::KNIGHT, + ), $legalMoves); + + $this->assertContainsEqualsOnce(new Move( + new Pawn(new Position(7, 1), Side::BLACK), + new Position(7, 0), + null, PieceType::ROOK, + ), $legalMoves); + + $this->assertContainsEqualsOnce(new Move( + new Pawn(new Position(7, 1), Side::BLACK), + new Position(7, 0), + null, PieceType::QUEEN, + ), $legalMoves); + } + + public function testLegalMoves_kingIsBlockedInitialPawnMove() { + $subject = new Game( + [ + new King(new Position(0, 0), Side::BLACK), + new King(new Position(3, 6), Side::WHITE), + new Queen(new Position(1, 2), Side::WHITE), + new Pawn(new Position(1, 6), Side::BLACK), + ], + Side::BLACK + ); + + $legalMoves = $subject->getLegalMoves(); + + $this->assertCount(2, $legalMoves); + + $this->assertContainsEqualsOnce(new Move( + new Pawn(new Position(1, 6), Side::BLACK), + new Position(1, 5), + null, null, + ), $legalMoves); + + $this->assertContainsEqualsOnce(new Move( + new Pawn(new Position(1, 6), Side::BLACK), + new Position(1, 4), + null, null, + ), $legalMoves); + } + + public function testLegalMoves_kingIsBlockedEnPassant() { + $opponentPawn = new Pawn(new Position(3, 1), Side::WHITE); + $opponentPawn->move(new Position(3, 3)); + + $subject = new Game( + [ + new King(new Position(0, 0), Side::BLACK), + new King(new Position(7, 6), Side::WHITE), + new Queen(new Position(1, 2), Side::WHITE), + $opponentPawn, + new Pawn(new Position(4, 3), Side::BLACK, true), + ], + Side::BLACK + ); + + $legalMoves = $subject->getLegalMoves(); + + $this->assertCount(2, $legalMoves); + + $this->assertContainsEqualsOnce(new Move( + new Pawn(new Position(4, 3), Side::BLACK), + new Position(4, 2), + null, null, + ), $legalMoves); + + $this->assertContainsEqualsOnce(new Move( + new Pawn(new Position(4, 3), Side::BLACK), + new Position(3, 2), + $opponentPawn, null, + ), $legalMoves); + } + + public function testLegalMoves_kingIsInCheckAttackerCanBeTaken() { + $subject = new Game( + [ + new King(new Position(0, 0), Side::BLACK), + new King(new Position(3, 6), Side::WHITE), + new Queen(new Position(1, 1), Side::WHITE), + new Queen(new Position(5, 1), Side::BLACK), + ], + Side::BLACK + ); + + $legalMoves = $subject->getLegalMoves(); + + $this->assertCount(2, $legalMoves); + + $this->assertContainsEqualsOnce(new Move( + new King(new Position(0, 0), Side::BLACK), + new Position(1, 1), + new Queen(new Position(1, 1), Side::WHITE), null, + ), $legalMoves); + } } \ No newline at end of file