Bug fixes to checks, pins and more. Reworking pawn movegen

This commit is contained in:
Mattia Giambirtone 2024-04-20 23:47:57 +02:00
parent d5bcd15c48
commit fe987576c3
5 changed files with 185 additions and 134 deletions

View File

@ -37,7 +37,7 @@ type
# The current position
position*: Position
# List of all previously reached positions
positions: seq[Position]
positions*: seq[Position]
# A bunch of simple utility functions and forward declarations
@ -53,7 +53,7 @@ proc removePiece(self: Chessboard, square: Square)
proc update*(self: Chessboard)
func inCheck*(self: Chessboard): bool {.inline.}
proc fromChar*(c: char): Piece
proc updateChecksAndPins*(self: Chessboard)
func kingSideRook*(color: PieceColor): Square {.inline.} = (if color == White: "h1".toSquare() else: "h8".toSquare())
@ -148,13 +148,13 @@ proc newChessboardFromFEN*(fen: string): Chessboard =
of '-':
discard
of 'K':
discard
result.position.castlingAvailability.white.king = true
of 'Q':
discard
result.position.castlingAvailability.white.queen = true
of 'k':
discard
result.position.castlingAvailability.black.king = true
of 'q':
discard
result.position.castlingAvailability.black.queen = true
else:
raise newException(ValueError, &"invalid FEN: unknown symbol '{c}' found in castlingRights availability section")
of 3:
@ -187,6 +187,7 @@ proc newChessboardFromFEN*(fen: string): Chessboard =
else:
raise newException(ValueError, "invalid FEN: too many fields in FEN string")
inc(index)
result.updateChecksAndPins()
proc newDefaultChessboard*: Chessboard {.inline.} =
@ -233,8 +234,7 @@ func getOccupancy(self: Chessboard): Bitboard {.inline.} =
func getPawnAttacks(self: Chessboard, square: Square, attacker: PieceColor): Bitboard {.inline.} =
## Returns the attack bitboard for the given square from
## the pawns of the given side
## Returns the locations of the pawns attacking the given square
let
sq = square.toBitboard()
pawns = self.getBitboard(Pawn, attacker)
@ -244,8 +244,7 @@ func getPawnAttacks(self: Chessboard, square: Square, attacker: PieceColor): Bit
func getKingAttacks(self: Chessboard, square: Square, attacker: PieceColor): Bitboard {.inline.} =
## Returns the attack bitboard for the given square from
## the king of the given side
## Returns the location of the king if it is attacking the given square
result = Bitboard(0)
let
king = self.getBitboard(King, attacker)
@ -254,8 +253,7 @@ func getKingAttacks(self: Chessboard, square: Square, attacker: PieceColor): Bit
func getKnightAttacks(self: Chessboard, square: Square, attacker: PieceColor): Bitboard =
## Returns the attack bitboard for the given square from
## the knights of the given side
## Returns the locations of the knights attacking the given square
let
knights = self.getBitboard(Knight, attacker)
result = Bitboard(0)
@ -266,24 +264,27 @@ func getKnightAttacks(self: Chessboard, square: Square, attacker: PieceColor): B
proc getSlidingAttacks(self: Chessboard, square: Square, attacker: PieceColor): Bitboard =
## Returns the attack bitboard for the given square from
## the sliding pieces of the given side
## Returns the locations of the sliding pieces attacking the given square
let
queens = self.getBitboard(Queen, attacker)
rooks = self.getBitboard(Rook, attacker) or queens
bishops = self.getBitboard(Bishop, attacker) or queens
occupancy = self.getOccupancy()
squareBB = square.toBitboard()
result = Bitboard(0)
for rook in rooks:
let blockers = Rook.getRelevantBlockers(square)
let rookBB = rook.toBitboard()
if (getRookMoves(square, blockers) and rookBB) != 0:
result = result or rookBB
let
blockers = occupancy and Rook.getRelevantBlockers(rook)
moves = getRookMoves(rook, blockers)
# Attack set intersects our chosen square
if (moves and squareBB) != 0:
result = result or rook.toBitboard()
for bishop in bishops:
let
blockers = Bishop.getRelevantBlockers(square)
bishopBB = bishop.toBitboard()
if (getBishopMoves(square, blockers) and bishopBB) != 0:
result = result or bishopBB
blockers = occupancy and Bishop.getRelevantBlockers(bishop)
moves = getBishopMoves(bishop, blockers)
if (moves and squareBB) != 0:
result = result or bishop.toBitboard()
proc getAttacksTo*(self: Chessboard, square: Square, attacker: PieceColor): Bitboard =
@ -296,7 +297,53 @@ proc getAttacksTo*(self: Chessboard, square: Square, attacker: PieceColor): Bitb
result = result or self.getSlidingAttacks(square, attacker)
proc updateChecksAndPins(self: Chessboard) =
proc isOccupancyAttacked*(self: Chessboard, square: Square, occupancy: Bitboard): bool =
## Returns whether the given square would be attacked by the
## enemy side if the board had the given occupancy. This function
## is necessary mostly to make sure sliding attacks can check the
## king properly: due to how we generate our attack bitboards, if
## the king moved backwards along a ray from a slider we would not
## consider it to be in check (because the ray stops at the first
## blocker). In order to fix that, in generateKingMoves() we use this
## function and pass in the board's occupancy without the moving king so
## that we can pick the correct magic bitboard and ray. Also, since this
## function doesn't need to generate all the attacks to know whether a
## given square is unsafe, it can short circuit at the first attack and
## exit early, unlike getAttacksTo
let
sideToMove = self.position.sideToMove
nonSideToMove = sideToMove.opposite()
knights = self.getBitboard(Knight, nonSideToMove)
# Let's do the cheap ones first (the ones which are precomputed)
if (getKnightAttacks(square) and knights) != 0:
return true
let king = self.getBitboard(King, nonSideToMove)
if (getKingAttacks(square) and king) != 0:
return true
let
queens = self.getBitboard(Queen, nonSideToMove)
bishops = self.getBitboard(Bishop, nonSideToMove) or queens
if (getBishopMoves(square, occupancy) and bishops) != 0:
return true
let rooks = self.getBitboard(Rook, nonSideToMove) or queens
if (getRookMoves(square, occupancy) and rooks) != 0:
return true
# TODO: Precompute pawn moves as well?
let pawns = self.getBitboard(Pawn, nonSideToMove)
if (self.getPawnAttacks(square, nonSideToMove) and pawns) != 0:
return true
proc updateChecksAndPins*(self: Chessboard) =
## Updates internal metadata about checks and
## pinned pieces
@ -305,8 +352,7 @@ proc updateChecksAndPins(self: Chessboard) =
let
sideToMove = self.position.sideToMove
nonSideToMove = sideToMove.opposite()
friendlyKingBB = self.getBitboard(King, sideToMove)
friendlyKing = friendlyKingBB.toSquare()
friendlyKing = self.getBitboard(King, sideToMove).toSquare()
friendlyPieces = self.getOccupancyFor(sideToMove)
enemyPieces = self.getOccupancyFor(nonSideToMove)
@ -325,14 +371,15 @@ proc updateChecksAndPins(self: Chessboard) =
for piece in canPinDiagonally:
let pinningRay = getRayBetween(friendlyKing, piece) or piece.toBitboard()
if (pinningRay and friendlyKingBB).countSquares() > 0:
# Is the pinning ray obstructed by any of our friendly pieces? If so, the
# piece is pinned
if (pinningRay and friendlyPieces).countSquares() > 0:
self.position.diagonalPins = self.position.diagonalPins or pinningRay
for piece in canPinOrthogonally:
let pinningRay = getRayBetween(friendlyKing, piece) or piece.toBitboard()
if (pinningRay and friendlyKingBB).countSquares() > 0:
self.position.diagonalPins = self.position.diagonalPins or pinningRay
if (pinningRay and friendlyPieces).countSquares() > 0:
self.position.orthogonalPins = self.position.orthogonalPins or pinningRay
func inCheck(self: Chessboard): bool {.inline.} =
@ -502,85 +549,49 @@ proc unmakeMove*(self: Chessboard) =
self.update()
proc generatePawnMovements(self: Chessboard, moves: var MoveList) =
## Helper of generatePawnMoves for generating all non-capture
## and non-promotion pawn moves
proc generatePawnMoves(self: Chessboard, moves: var MoveList, mask: Bitboard) =
let
sideToMove = self.position.sideToMove
pawns = self.getBitboard(Pawn, sideToMove)
# We can only move to squares that are *not* occupied by another piece.
# We also cannot move to the last rank, as that will result in a promotion
# and is handled elsewhere
allowedSquares = not (self.getOccupancy() or sideToMove.getLastRank())
# Single push
for square in pawns.forwardRelativeTo(sideToMove) and allowedSquares:
moves.add(createMove(square.toBitboard().backwardRelativeTo(sideToMove), square))
# Double push
let rank = if sideToMove == White: getRankMask(6) else: getRankMask(1) # Only pawns on their starting rank can double push
for square in (pawns and rank).doubleForwardRelativeTo(sideToMove) and allowedSquares:
moves.add(createMove(square.toBitboard().doubleBackwardRelativeTo(sideToMove), square, DoublePush))
proc generatePawnCaptures(self: Chessboard, moves: var MoveList) =
## Helper of generatePawnMoves for generating all capturing
## moves
let
sideToMove = self.position.sideToMove
nonSideToMove = sideToMove.opposite()
pawns = self.getBitboard(Pawn, sideToMove)
occupancy = self.getOccupancy()
# We can only capture enemy pieces (except the king)
enemyPieces = self.getOccupancyFor(nonSideToMove) and not self.getBitboard(King, nonSideToMove)
enemyPawns = self.getBitboard(Pawn, nonSideToMove)
enemyPieces = self.getOccupancyFor(sideToMove.opposite())
# We can only capture diagonally and forward
rightMovement = pawns.forwardRightRelativeTo(sideToMove)
leftMovement = pawns.forwardLeftRelativeTo(sideToMove)
epTarget = self.position.enPassantSquare
var epBitboard = if (epTarget != nullSquare()): epTarget.toBitboard() else: Bitboard(0)
# TODO: Remove this. Seems like we're not keeping track of en passant targets properly and
# trying to do en passant on top of a piece
epBitboard = epBitboard and not occupancy
# Top right attacks
for square in rightMovement and enemyPieces:
moves.add(createMove(square.toBitboard().backwardLeftRelativeTo(sideToMove), square, Capture))
# Top left attacks
for square in leftMovement and enemyPieces:
moves.add(createMove(square.toBitboard().backwardRightRelativeTo(sideToMove), square, Capture))
# Special case for en passant
checkers = self.position.checkers
diagonalPins = self.position.diagonalPins
orthogonalPins = self.position.orthogonalPins
promotionRank = if sideToMove == White: getRankMask(0) else: getRankMask(7)
# The rank where each color's side starts
# TODO: Give names to ranks and files so we don't have to assume a
# specific board layout when calling get(Rank|File)Mask
startingRank = if sideToMove == White: getRankMask(6) else: getRankMask(1)
var epBitboard = if epTarget != nullSquare(): epTarget.toBitboard() else: Bitboard(0)
let epPawn = if epBitboard == 0: Bitboard(0) else: epBitboard.forwardRelativeTo(sideToMove)
# If we are in check, en passant is only possible if we'd capture the (only)
# checking pawn with it
if epBitboard != 0 and self.inCheck() and (epPawn and checkers).countSquares() == 0:
epBitboard = Bitboard(0)
# Single and double pushes
let
epLeft = epBitboard and leftMovement
epRight = epBitboard and rightMovement
if epLeft != 0:
moves.add(createMove(epBitboard.forwardLeftRelativeTo(nonSideToMove), epBitboard, EnPassant))
elif epRight != 0:
moves.add(createMove(epBitboard.forwardRightRelativeTo(nonSideToMove), epBitboard, EnPassant))
# If a pawn is pinned diagonally, it cannot move
pushablePawns = pawns and not diagonalPins
# Neither can it move if it's pinned orthogonally
singlePushes = pushablePawns.forwardRelativeTo(sideToMove) and not occupancy and not orthogonalPins
# Only pawns on their starting rank can double push
doublePushes = (pushablePawns and startingRank).doubleForwardRelativeTo(sideToMove) and not occupancy and orthogonalPins
proc generatePawnPromotions(self: Chessboard, moves: var MoveList) =
## Helper of generatePawnMoves for generating all promotion
## moves
let
sideToMove = self.position.sideToMove
pawns = self.getBitboard(Pawn, sideToMove)
occupancy = self.getOccupancy()
for square in pawns.forwardRelativeTo(sideToMove) and not occupancy and sideToMove.getLastRank():
for promotion in [PromoteToBishop, PromoteToKnight, PromoteToQueen, PromoteToRook]:
moves.add(createMove(square.toBitboard().backwardRelativeTo(sideToMove), square, promotion))
proc generatePawnMoves(self: Chessboard, moves: var MoveList) =
## Generates all the legal pawn moves for the side to move
self.generatePawnMovements(moves)
self.generatePawnCaptures(moves)
self.generatePawnPromotions(moves)
proc generateRookMoves(self: Chessboard, moves: var MoveList) =
proc generateRookMoves(self: Chessboard, moves: var MoveList, mask: Bitboard) =
## Helper of generateSlidingMoves to generate rook moves
let
sideToMove = self.position.sideToMove
occupancy = self.getOccupancy()
nonSideToMove = sideToMove.opposite()
enemyPieces = self.getOccupancyFor(sideToMove.opposite()) and not self.getBitboard(King, nonSideToMove)
enemyPieces = self.getOccupancyFor(sideToMove.opposite())
rooks = self.getBitboard(Rook, sideToMove)
queens = self.getBitboard(Queen, sideToMove)
movableRooks = not self.position.diagonalPins and (queens or rooks)
@ -591,27 +602,26 @@ proc generateRookMoves(self: Chessboard, moves: var MoveList) =
let
blockers = occupancy and Rook.getRelevantBlockers(square)
moveset = getRookMoves(square, blockers)
for target in moveset and not occupancy and pinMask:
for target in moveset and not occupancy and pinMask and mask:
moves.add(createMove(square, target))
for target in moveset and enemyPieces and pinMask:
for target in moveset and enemyPieces and pinMask and mask:
moves.add(createMove(square, target, Capture))
for square in unpinnedRooks:
let
blockers = occupancy and Rook.getRelevantBlockers(square)
moveset = getRookMoves(square, blockers)
for target in moveset and not occupancy:
for target in moveset and not occupancy and mask:
moves.add(createMove(square, target))
for target in moveset and enemyPieces:
for target in moveset and enemyPieces and mask:
moves.add(createMove(square, target, Capture))
proc generateBishopMoves(self: Chessboard, moves: var MoveList) =
proc generateBishopMoves(self: Chessboard, moves: var MoveList, mask: Bitboard) =
## Helper of generateSlidingMoves to generate bishop moves
let
sideToMove = self.position.sideToMove
occupancy = self.getOccupancy()
nonSideToMove = sideToMove.opposite()
enemyPieces = self.getOccupancyFor(sideToMove.opposite()) and not self.getBitboard(King, nonSideToMove)
enemyPieces = self.getOccupancyFor(sideToMove.opposite())
bishops = self.getBitboard(Bishop, sideToMove)
queens = self.getBitboard(Queen, sideToMove)
movableBishops = not self.position.orthogonalPins and (queens or bishops)
@ -622,27 +632,20 @@ proc generateBishopMoves(self: Chessboard, moves: var MoveList) =
let
blockers = occupancy and Bishop.getRelevantBlockers(square)
moveset = getBishopMoves(square, blockers)
for target in moveset and pinMask and not occupancy:
for target in moveset and pinMask and mask:
moves.add(createMove(square, target))
for target in moveset and enemyPieces and pinMask:
for target in moveset and enemyPieces and pinMask and mask:
moves.add(createMove(square, target, Capture))
for square in unpinnedBishops:
let
blockers = occupancy and Bishop.getRelevantBlockers(square)
moveset = getBishopMoves(square, blockers)
for target in moveset and not occupancy:
for target in moveset and mask:
moves.add(createMove(square, target))
for target in moveset and enemyPieces:
for target in moveset and enemyPieces and mask:
moves.add(createMove(square, target, Capture))
proc generateSlidingMoves(self: Chessboard, moves: var MoveList) =
## Generates all legal sliding moves for the side to move
self.generateRookMoves(moves)
self.generateBishopMoves(moves)
# Queens are just handled rooks + bishops
proc generateKingMoves(self: Chessboard, moves: var MoveList) =
## Generates all legal king moves for the side to move
let
@ -652,25 +655,27 @@ proc generateKingMoves(self: Chessboard, moves: var MoveList) =
nonSideToMove = sideToMove.opposite()
enemyPieces = self.getOccupancyFor(nonSideToMove) and not self.getBitboard(King, nonSideToMove)
bitboard = getKingAttacks(king.toSquare())
noKingOccupancy = occupancy and not king
for square in bitboard and not occupancy:
moves.add(createMove(king, square))
if not self.isOccupancyAttacked(square, noKingOccupancy):
moves.add(createMove(king, square))
for square in bitboard and enemyPieces:
moves.add(createMove(king, square, Capture))
if not self.isOccupancyAttacked(square, noKingOccupancy):
moves.add(createMove(king, square, Capture))
proc generateKnightMoves(self: Chessboard, moves: var MoveList)=
proc generateKnightMoves(self: Chessboard, moves: var MoveList, mask: Bitboard) =
## Generates all the legal knight moves for the side to move
let
sideToMove = self.position.sideToMove
knights = self.getBitboard(Knight, sideToMove)
occupancy = self.getOccupancy()
nonSideToMove = sideToMove.opposite()
pinned = self.position.diagonalPins or self.position.orthogonalPins
unpinnedKnights = knights and not pinned
enemyPieces = self.getOccupancyFor(nonSideToMove) and not self.getBitboard(King, nonSideToMove)
for square in unpinnedKnights:
let bitboard = getKnightAttacks(square)
for target in bitboard and not occupancy:
for target in bitboard and mask:
moves.add(createMove(square, target))
for target in bitboard and enemyPieces:
moves.add(createMove(square, target, Capture))
@ -682,6 +687,7 @@ proc generateMoves*(self: Chessboard, moves: var MoveList) =
if self.position.halfMoveClock >= 100:
# Draw by 50-move rule
return
let sideToMove = self.position.sideToMove
# TODO: Check for draw by insufficient material
# TODO: Check for repetitions (requires zobrist hashing + table)
self.generateKingMoves(moves)
@ -692,10 +698,32 @@ proc generateMoves*(self: Chessboard, moves: var MoveList) =
if not self.inCheck():
# TODO: Castling
discard
# We pass a mask to our move generators to remove stuff
# like our friendly pieces from the set of possible
# target squares, as well as to ensure checks are not
# ignored
self.generatePawnMoves(moves)
self.generateKnightMoves(moves)
self.generateSlidingMoves(moves)
var mask: Bitboard
if not self.inCheck():
# Not in check: cannot move over friendly pieces
mask = not self.getOccupancyFor(sideToMove)
else:
# We *are* in check (from a single piece, because the two checks
# case was handled above already). If the piece is a slider, we'll
# extract the ray from it to our king and add the checking piece to
# it, meaning the only legal moves are those that either block the
# check or capture the checking piece. For other non-sliding pieces
# the ray will be empty so the only legal move will be to capture
# the checking piece
let checker = self.position.checkers.lowestSquare()
mask = getRayBetween(checker, self.getBitboard(King, sideToMove).toSquare()) or checker.toBitboard()
self.generatePawnMoves(moves, mask)
self.generateKnightMoves(moves, mask)
self.generateRookMoves(moves, mask)
self.generateBishopMoves(moves, mask)
# Queens are just handled rooks + bishops
proc isLegal(self: Chessboard, move: Move): bool {.inline.} =
@ -863,20 +891,19 @@ proc toFEN*(self: Chessboard): string =
result &= (if self.position.sideToMove == White: "w" else: "b")
result &= " "
# Castling availability
result &= "-"
# let castleWhite = self.position.castlingRightsAvailable.white
# let castleBlack = self.position.castlingRightsAvailable.black
# if not (castleBlack.king or castleBlack.queen or castleWhite.king or castleWhite.queen):
# result &= "-"
# else:
# if castleWhite.king:
# result &= "K"
# if castleWhite.queen:
# result &= "Q"
# if castleBlack.king:
# result &= "k"
# if castleBlack.queen:
# result &= "q"
let castleWhite = self.position.castlingAvailability.white
let castleBlack = self.position.castlingAvailability.black
if not (castleBlack.king or castleBlack.queen or castleWhite.king or castleWhite.queen):
result &= "-"
else:
if castleWhite.king:
result &= "K"
if castleWhite.queen:
result &= "Q"
if castleBlack.king:
result &= "k"
if castleBlack.queen:
result &= "q"
result &= " "
# En passant target
if self.position.enPassantSquare == nullSquare():

View File

@ -61,6 +61,12 @@ func countSquares*(self: Bitboard): int {.inline.} =
result = self.countSetBits()
func lowestSquare*(self: Bitboard): Square {.inline.} =
## Returns the index of the lowest one bit
## in the given bitboard as a square
result = Square(self.countTrailingZeroBits().uint8)
func getFileMask*(file: int): Bitboard = Bitboard(0x101010101010101'u64) shl file.uint64
func getRankMask*(rank: int): Bitboard = Bitboard(0xff) shl uint64(8 * rank)
func toBitboard*(square: SomeInteger): Bitboard = Bitboard(1'u64) shl square.uint64

View File

@ -11,6 +11,11 @@ type
# Castling metadata. Updated on every move
castlingRights*: array[64, uint8]
# Castling availability. This just keeps track
# of whether the king or the rooks on either side
# moved and is only useful for printing the correct
# FEN for a given position
castlingAvailability*: tuple[white, black: tuple[queen, king: bool]]
# Number of half-moves that were performed
# to reach this position starting from the
# root of the tree

View File

@ -345,6 +345,8 @@ const HELP_TEXT = """Nimfish help menu:
- pos <args>: Shorthand for "position <args>"
- get <square>: Get the piece on the given square
- atk <square>: Print the attack bitboard of the given square for the side to move
- pins: Print the current pin mask
- checks: Print the current checks mask
- skip: Swap the side to move
- uci: enter UCI mode (WIP)
- quit: exit
@ -380,6 +382,7 @@ proc commandLoop*: int =
echo HELP_TEXT
of "skip":
board.position.sideToMove = board.position.sideToMove.opposite()
board.updateChecksAndPins()
of "go":
handleGoCommand(board, cmd)
of "position", "pos":
@ -389,7 +392,10 @@ proc commandLoop*: int =
of "pretty", "print", "fen":
handlePositionCommand(board, @["position", cmd[0]])
of "unmove", "u":
board.unmakeMove()
if board.positions.len() == 0:
echo "No previous move to undo"
else:
board.unmakeMove()
of "stm":
echo &"Side to move: {board.position.sideToMove}"
of "atk":
@ -421,6 +427,11 @@ proc commandLoop*: int =
echo &"Castling rights for {($board.position.sideToMove).toLowerAscii()}:\n - King side: {(if canCastle.king: \"yes\" else: \"no\")}\n - Queen side: {(if canCastle.queen: \"yes\" else: \"no\")}"
of "check":
echo &"{board.position.sideToMove} king in check: {(if board.inCheck(): \"yes\" else: \"no\")}"
of "pins":
echo &"Ortogonal pins:\n{board.position.orthogonalPins}"
echo &"Diagonal pins:\n{board.position.diagonalPins}"
of "checks":
echo board.position.checkers
of "quit":
return 0
else:

View File

@ -10,6 +10,8 @@ from argparse import ArgumentParser, Namespace
def main(args: Namespace) -> int:
if args.silent:
print = lambda *_: ...
else:
print = __builtins__.print
print("Nimfish move validator v0.0.1 by nocturn9x")
try:
STOCKFISH = (args.stockfish or Path(which("stockfish"))).resolve(strict=True)