feat: Add game system function for fetching legal moves in position

This commit is contained in:
overflowerror 2024-01-05 00:22:13 +01:00
parent 2848208132
commit ba9fc502c3
7 changed files with 461 additions and 1 deletions

View file

@ -21,6 +21,22 @@ class Game {
return array_filter($this->pieces, fn($p) => $p->getSide() == $side); 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 { private function getKing(Side $side): King {
if ($side == Side::WHITE) { if ($side == Side::WHITE) {
return $this->whiteKing; return $this->whiteKing;
@ -42,6 +58,24 @@ class Game {
return $this->getOccupied($this->pieces); 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 { private function isInCheck(Side $side, FieldBitMap $allOccupied): bool {
$opponentPieces = $this->getPieces($side->getNext()); $opponentPieces = $this->getPieces($side->getNext());
$king = $this->getKing($side); $king = $this->getKing($side);
@ -65,6 +99,131 @@ class Game {
return $this->isInCheck($this->current->getNext(), $allOccupied); 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 { public function getGameState(): GameState {
$allOccupied = $this->getAllOccupied(); $allOccupied = $this->getAllOccupied();
@ -81,6 +240,10 @@ class Game {
return GameState::DEFAULT; return GameState::DEFAULT;
} }
public function __clone(): void {
$this->pieces = array_map(fn($p) => clone $p, $this->pieces);
}
public function visualize(): string { public function visualize(): string {
} }

33
src/Game/Move.php Normal file
View file

@ -0,0 +1,33 @@
<?php
namespace Game;
class Move {
public Piece $piece;
public Position $target;
public ?Piece $captures = null;
public ?PieceType $promoteTo = null;
public function __construct(Piece $piece, Position $target, ?Piece $captures = null, ?PieceType $promoteTo = null) {
$this->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() : "");
}
}

View file

@ -68,4 +68,8 @@ class Pawn extends Piece {
return $result; return $result;
} }
public function canPromote(Position $position): bool {
return ($this->side == Side::WHITE) ? ($position->rank == 7) : ($position->rank == 0);
}
} }

View file

@ -9,9 +9,10 @@ abstract class Piece {
protected bool $wasMovedLast = false; protected bool $wasMovedLast = false;
protected ?Position $oldPosition = null; 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->position = $position;
$this->side = $side; $this->side = $side;
$this->hasMoved = $hasMoved;
} }
public function tick() { public function tick() {
@ -46,4 +47,44 @@ abstract class Piece {
public function __toString() { public function __toString() {
return $this->getShort() . $this->getPosition(); 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;
}
} }

27
src/Game/PieceType.php Normal file
View file

@ -0,0 +1,27 @@
<?php
namespace Game;
enum PieceType {
case PAWN;
case BISHOP;
case KNIGHT;
case ROOK;
case QUEEN;
case KING;
public static function getPromotionPieces() {
return [PieceType::BISHOP, PieceType::KNIGHT, PieceType::ROOK, PieceType::QUEEN];
}
public function getShort(): string {
return match ($this) {
self::PAWN => "",
self::BISHOP => "B",
self::KNIGHT => "N",
self::ROOK => "R",
self::QUEEN => "Q",
self::KING => "K",
};
}
}

View file

@ -20,4 +20,8 @@ class Position {
public function __toString(): string { public function __toString(): string {
return ["a", "b", "c", "d", "e", "f", "g", "h"][$this->file] . ($this->rank + 1); 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;
}
} }

View file

@ -6,6 +6,26 @@ use PHPUnit\Framework\TestCase;
final class GameTest extends 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() { public function testGameState_illegal_white() {
$subject = new Game( $subject = new Game(
[ [
@ -57,4 +77,172 @@ final class GameTest extends TestCase {
$this->assertEquals(GameState::CHECK, $subject->getGameState()); $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);
}
} }