1
0
Fork 0
eschac/src/setup.rs
2025-09-03 20:57:14 +02:00

659 lines
22 KiB
Rust

//! Building chess positions.
//!
//! [`Setup`] is a builder for the [`Position`] type.
use crate::bitboard::*;
use crate::board::*;
use crate::lookup::*;
use crate::position::*;
/// **A builder type for chess positions.**
///
/// This type is useful to edit a position without having to ensure it stays legal at every step.
/// It must be validated and converted to a [`Position`] using the [`Setup::validate`] method
/// before generating moves.
///
/// This type implements [`FromStr`](std::str::FromStr) and [`Display`](std::fmt::Display) to parse
/// and print positions from text records.
///
/// Forsyth-Edwards Notation (FEN) is typically used to describe chess positions as text. eschac
/// uses a slightly different notation, which simply removes the last two fields of the FEN string
/// (i.e. the halfmove clock and the fullmove number) as the [`Position`] type does not keep
/// track of those.
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Setup {
pub(crate) w: Bitboard,
pub(crate) p_b_q: Bitboard,
pub(crate) n_b_k: Bitboard,
pub(crate) r_q_k: Bitboard,
pub(crate) turn: Color,
pub(crate) en_passant: OptionSquare,
pub(crate) castling_rights: CastlingRights,
}
impl Setup {
/// Creates an empty board, i.e. `8/8/8/8/8/8/8/8 w - -`.
#[inline]
pub fn new() -> Self {
Self {
w: Bitboard(0),
p_b_q: Bitboard(0),
n_b_k: Bitboard(0),
r_q_k: Bitboard(0),
turn: Color::White,
en_passant: OptionSquare::None,
castling_rights: CastlingRights::new(),
}
}
/// Reads a position from an ascii record.
pub fn from_ascii(s: &[u8]) -> Result<Self, ParseSetupError> {
let mut s = s.iter().copied().peekable();
let mut setup = Setup::new();
(|| {
let mut accept_empty_square = true;
let mut rank: u8 = 7;
let mut file: u8 = 0;
for c in s.by_ref() {
if c == b'/' {
(file == 8).then_some(())?;
rank = rank.checked_sub(1)?;
file = 0;
accept_empty_square = true;
} else if (b'1'..=b'8').contains(&c) && accept_empty_square {
file = file + c - b'0';
(file <= 8).then_some(())?;
accept_empty_square = false;
} else if c == b' ' {
break;
} else {
let role = Role::from_ascii(c)?;
let color = match c.is_ascii_uppercase() {
true => Color::White,
false => Color::Black,
};
(file < 8).then_some(())?;
setup.set(
unsafe { Square::new(File::transmute(file), Rank::transmute(rank)) },
Some(Piece { role, color }),
);
file += 1;
accept_empty_square = true;
}
}
(rank == 0).then_some(())?;
(file == 8).then_some(())?;
Some(())
})()
.ok_or(ParseSetupError::InvalidBoard)?;
(|| {
match s.next()? {
b'w' => setup.set_turn(Color::White),
b'b' => setup.set_turn(Color::Black),
_ => return None,
}
(s.next()? == b' ').then_some(())
})()
.ok_or(ParseSetupError::InvalidTurn)?;
(|| {
if s.next_if_eq(&b'-').is_none() {
if s.next_if_eq(&b'K').is_some() {
setup.set_castling_rights(Color::White, CastlingSide::Short, true);
}
if s.next_if_eq(&b'Q').is_some() {
setup.set_castling_rights(Color::White, CastlingSide::Long, true);
}
if s.next_if_eq(&b'k').is_some() {
setup.set_castling_rights(Color::Black, CastlingSide::Short, true);
}
if s.next_if_eq(&b'q').is_some() {
setup.set_castling_rights(Color::Black, CastlingSide::Long, true);
}
}
(s.next()? == b' ').then_some(())
})()
.ok_or(ParseSetupError::InvalidCastlingRights)?;
(|| {
match s.next()? {
b'-' => (),
file => setup.set_en_passant_target_square(Some(Square::new(
File::from_ascii(file)?,
Rank::from_ascii(s.next()?)?,
))),
}
s.next().is_none().then_some(())
})()
.ok_or(ParseSetupError::InvalidEnPassantTargetSquare)?;
Ok(setup)
}
/// Returns the occupancy of a square.
#[inline]
pub fn get(&self, square: Square) -> Option<Piece> {
Some(Piece {
role: self.get_role(square)?,
color: match (self.w & square.bitboard()).is_empty() {
false => Color::White,
true => Color::Black,
},
})
}
/// Returns the color to play.
#[inline]
pub fn turn(&self) -> Color {
self.turn
}
/// Returns `true` if castling is available for the given color and side.
#[inline]
pub fn castling_rights(&self, color: Color, side: CastlingSide) -> bool {
self.castling_rights.get(color, side)
}
/// Returns the optional en passant target square.
#[inline]
pub fn en_passant_target_square(&self) -> Option<Square> {
self.en_passant.try_into_square()
}
/// Sets the occupancy of a square.
#[inline]
pub fn set(&mut self, square: Square, piece: Option<Piece>) {
let mask = !square.bitboard();
self.w &= mask;
self.p_b_q &= mask;
self.n_b_k &= mask;
self.r_q_k &= mask;
if let Some(piece) = piece {
let to = square.bitboard();
match piece.color {
Color::White => self.w |= to,
Color::Black => (),
}
match piece.role {
Role::Pawn => {
self.p_b_q |= to;
}
Role::Knight => {
self.n_b_k |= to;
}
Role::Bishop => {
self.p_b_q |= to;
self.n_b_k |= to;
}
Role::Rook => {
self.r_q_k |= to;
}
Role::Queen => {
self.p_b_q |= to;
self.r_q_k |= to;
}
Role::King => {
self.n_b_k |= to;
self.r_q_k |= to;
}
}
}
}
/// Sets the color to play.
#[inline]
pub fn set_turn(&mut self, color: Color) {
self.turn = color;
}
/// Sets the castling rights for the given color and side.
#[inline]
pub fn set_castling_rights(&mut self, color: Color, side: CastlingSide, value: bool) {
match value {
true => self.castling_rights.set(color, side),
false => self.castling_rights.unset(color, side),
}
}
/// Sets the en passant target square.
#[inline]
pub fn set_en_passant_target_square(&mut self, square: Option<Square>) {
self.en_passant = OptionSquare::new(square);
}
/// Returns the mirror image of the position.
///
/// The mirror of a position is the position obtained after reflecting the placement of pieces
/// horizontally, inverting the color of all the pieces, inverting the turn, and reflecting the
/// castling rights as well as the en passant target square.
///
/// For example, the mirror image of `rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b Kq e3`
/// is `rnbqkbnr/pppp1ppp/8/4p3/8/8/PPPPPPPP/RNBQKBNR w Qk e6`.
#[inline]
pub fn mirror(&self) -> Self {
Self {
w: (self.w ^ (self.p_b_q | self.n_b_k | self.r_q_k)).mirror(),
p_b_q: self.p_b_q.mirror(),
n_b_k: self.n_b_k.mirror(),
r_q_k: self.r_q_k.mirror(),
turn: !self.turn,
en_passant: self
.en_passant
.try_into_square()
.map(|square| OptionSquare::from_square(square.mirror()))
.unwrap_or(OptionSquare::None),
castling_rights: self.castling_rights.mirror(),
}
}
/// Tries to validate the position, i.e. converting it to a [`Position`].
///
/// See [`IllegalPositionReason`] for details.
pub fn validate(self) -> Result<Position, IllegalPosition> {
debug_assert!((self.w & !(self.p_b_q | self.n_b_k | self.r_q_k)).is_empty());
debug_assert!((self.p_b_q & self.n_b_k & self.r_q_k).is_empty());
let mut reasons = IllegalPositionReasons::new();
let d = InitialisedLookup::init();
let blockers = self.p_b_q | self.n_b_k | self.r_q_k;
let pieces = self.bitboards();
if Color::all()
.into_iter()
.any(|color| pieces.get(color).king().is_empty())
{
reasons.add(IllegalPositionReason::MissingKing);
}
if Color::all()
.into_iter()
.any(|color| pieces.get(color).get(Role::King).len() > 1)
{
reasons.add(IllegalPositionReason::TooManyKings);
}
if pieces.get(!self.turn).king().any(|enemy_king| {
let pieces = pieces.get(self.turn);
!(d.king(enemy_king) & *pieces.get(Role::King)
| d.bishop(enemy_king, blockers)
& (*pieces.get(Role::Queen) | *pieces.get(Role::Bishop))
| d.rook(enemy_king, blockers)
& (*pieces.get(Role::Queen) | *pieces.get(Role::Rook))
| d.knight(enemy_king) & *pieces.get(Role::Knight)
| d.pawn_attack(!self.turn, enemy_king) & *pieces.get(Role::Pawn))
.is_empty()
}) {
reasons.add(IllegalPositionReason::HangingKing);
}
if Color::all().into_iter().any(|color| {
!(*pieces.get(color).get(Role::Pawn)
& (Rank::First.bitboard() | Rank::Eighth.bitboard()))
.is_empty()
}) {
reasons.add(IllegalPositionReason::PawnOnBackRank);
}
if Color::all().into_iter().any(|color| {
let dark_squares = Bitboard(0xAA55AA55AA55AA55);
let light_squares = Bitboard(0x55AA55AA55AA55AA);
let pieces = pieces.get(color);
pieces.get(Role::Pawn).len()
+ pieces.get(Role::Queen).len().saturating_sub(1)
+ (*pieces.get(Role::Bishop) & dark_squares)
.len()
.saturating_sub(1)
+ (*pieces.get(Role::Bishop) & light_squares)
.len()
.saturating_sub(1)
+ pieces.get(Role::Knight).len().saturating_sub(2)
+ pieces.get(Role::Rook).len().saturating_sub(2)
> 8
}) {
reasons.add(IllegalPositionReason::TooMuchMaterial);
}
if Color::all().into_iter().any(|color| {
CastlingSide::all().into_iter().any(|side| {
self.castling_rights.get(color, side)
&& !(pieces
.get(color)
.get(Role::King)
.contains(Square::new(File::E, color.home_rank()))
&& pieces
.get(color)
.get(Role::Rook)
.contains(Square::new(side.rook_origin_file(), color.home_rank())))
})
}) {
reasons.add(IllegalPositionReason::InvalidCastlingRights);
}
if self.en_passant.try_into_square().is_some_and(|en_passant| {
let (target_rank, pawn_rank) = match self.turn {
Color::White => (Rank::Sixth, Rank::Fifth),
Color::Black => (Rank::Third, Rank::Fourth),
};
let pawn_square = Square::new(en_passant.file(), pawn_rank);
en_passant.rank() != target_rank
|| blockers.contains(en_passant)
|| !pieces.get(!self.turn).get(Role::Pawn).contains(pawn_square)
}) {
reasons.add(IllegalPositionReason::InvalidEnPassant);
}
if self.en_passant.try_into_square().is_some_and(|en_passant| {
let blockers = blockers
& !en_passant.bitboard().trans(match self.turn {
Color::White => Direction::South,
Color::Black => Direction::North,
});
pieces
.get(self.turn)
.king()
.first()
.is_some_and(|king_square| {
!(d.bishop(king_square, blockers)
& (pieces.get(!self.turn).queen() | pieces.get(!self.turn).bishop()))
.is_empty()
})
}) {
reasons.add(IllegalPositionReason::ImpossibleEnPassantPin);
}
if reasons.0 != 0 {
Err(IllegalPosition {
setup: self,
reasons,
})
} else {
Ok(unsafe { Position::from_setup(self) })
}
}
#[inline]
pub(crate) fn get_role(&self, square: Square) -> Option<Role> {
let mask = square.bitboard();
let bit0 = (self.p_b_q & mask).0 >> square as u8;
let bit1 = (self.n_b_k & mask).0 >> square as u8;
let bit2 = (self.r_q_k & mask).0 >> square as u8;
match bit0 | bit1 << 1 | bit2 << 2 {
0 => None,
i => Some(unsafe { Role::transmute(i as u8) }),
}
}
#[inline]
pub(crate) fn bitboards(&self) -> ByColor<ByRole<Bitboard>> {
let Self {
w,
p_b_q,
n_b_k,
r_q_k,
..
} = self.clone();
let k = n_b_k & r_q_k;
let q = p_b_q & r_q_k;
let b = p_b_q & n_b_k;
let n = n_b_k ^ b ^ k;
let r = r_q_k ^ q ^ k;
let p = p_b_q ^ b ^ q;
ByColor::new(|color| {
let mask = match color {
Color::White => w,
Color::Black => !w,
};
ByRole::new(|kind| {
mask & match kind {
Role::King => k,
Role::Queen => q,
Role::Bishop => b,
Role::Knight => n,
Role::Rook => r,
Role::Pawn => p,
}
})
})
}
}
impl std::fmt::Debug for Setup {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
f.debug_tuple("Setup").field(&self.to_string()).finish()
}
}
impl std::fmt::Display for Setup {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
use std::fmt::Write;
for rank in Rank::all().into_iter().rev() {
let mut count = 0;
for file in File::all() {
match self.get(Square::new(file, rank)) {
Some(piece) => {
if count > 0 {
f.write_char(char::from_u32('0' as u32 + count).unwrap())?;
}
count = 0;
f.write_char(match piece.color {
Color::White => piece.role.to_char_uppercase(),
Color::Black => piece.role.to_char_lowercase(),
})?;
}
None => {
count += 1;
}
}
}
if count > 0 {
f.write_char(char::from_u32('0' as u32 + count).unwrap())?;
}
if rank != Rank::First {
f.write_char('/')?;
}
}
f.write_char(' ')?;
f.write_char(match self.turn {
Color::White => 'w',
Color::Black => 'b',
})?;
f.write_char(' ')?;
let mut no_castle_available = true;
if self.castling_rights(Color::White, CastlingSide::Short) {
f.write_char('K')?;
no_castle_available = false;
}
if self.castling_rights(Color::White, CastlingSide::Long) {
f.write_char('Q')?;
no_castle_available = false;
}
if self.castling_rights(Color::Black, CastlingSide::Short) {
f.write_char('k')?;
no_castle_available = false;
}
if self.castling_rights(Color::Black, CastlingSide::Long) {
f.write_char('q')?;
no_castle_available = false;
}
if no_castle_available {
f.write_char('-')?;
}
f.write_char(' ')?;
match self.en_passant.try_into_square() {
Some(sq) => {
f.write_str(sq.to_str())?;
}
None => {
write!(f, "-")?;
}
}
Ok(())
}
}
impl std::str::FromStr for Setup {
type Err = ParseSetupError;
#[inline]
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::from_ascii(s.as_bytes())
}
}
/// An error when trying to parse a position record.
///
/// The variant indicates the field that caused the error.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ParseSetupError {
InvalidBoard,
InvalidTurn,
InvalidCastlingRights,
InvalidEnPassantTargetSquare,
}
impl std::fmt::Display for ParseSetupError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let details = match self {
Self::InvalidBoard => "board",
Self::InvalidTurn => "turn",
Self::InvalidCastlingRights => "castling rights",
Self::InvalidEnPassantTargetSquare => "en passant target square",
};
write!(f, "invalid text record ({details})")
}
}
impl std::error::Error for ParseSetupError {}
/// An invalid position.
///
/// This is an illegal position that can't be represented with the [`Position`] type.
#[derive(Debug)]
pub struct IllegalPosition {
setup: Setup,
reasons: IllegalPositionReasons,
}
impl std::fmt::Display for IllegalPosition {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
use std::fmt::Write;
let setup = &self.setup;
write!(f, "`{setup}` is illegal:")?;
let mut first = true;
for reason in self.reasons {
if !first {
f.write_char(',')?;
}
first = false;
write!(f, " {reason}")?;
}
Ok(())
}
}
impl std::error::Error for IllegalPosition {}
impl IllegalPosition {
/// Returns an iterator over the reasons why the position is rejected.
pub fn reasons(&self) -> IllegalPositionReasons {
self.reasons
}
/// Returns the [`Setup`] that failed validation.
pub fn as_setup(&self) -> &Setup {
&self.setup
}
/// Returns the [`Setup`] that failed validation.
pub fn into_setup(self) -> Setup {
self.setup
}
}
/// A set of [`IllegalPositionReason`]s.
#[derive(Clone, Copy)]
pub struct IllegalPositionReasons(u8);
impl std::fmt::Debug for IllegalPositionReasons {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_list().entries(*self).finish()
}
}
impl IllegalPositionReasons {
/// Returns `true` if the given reason appears in the set.
pub fn contains(&self, reason: IllegalPositionReason) -> bool {
(self.0 & reason as u8) != 0
}
fn new() -> Self {
IllegalPositionReasons(0)
}
fn add(&mut self, reason: IllegalPositionReason) {
self.0 |= reason as u8;
}
}
impl Iterator for IllegalPositionReasons {
type Item = IllegalPositionReason;
fn next(&mut self) -> Option<Self::Item> {
if self.0 == 0 {
None
} else {
let reason = 1 << self.0.trailing_zeros();
self.0 &= !reason;
Some(unsafe { std::mem::transmute::<u8, IllegalPositionReason>(reason) })
}
}
}
/// Reasons for illegal positions to be rejected by eschac.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum IllegalPositionReason {
/// One of the colors misses its king.
MissingKing = 1,
/// There is more than one king of the same color.
TooManyKings = 2,
/// The opponent's king is in check.
HangingKing = 4,
/// There is a pawn on the first or eighth rank.
PawnOnBackRank = 8,
/// Some castling rights are invalid regarding the positions of the rooks and kings.
InvalidCastlingRights = 16,
/// The en passant target square is invalid, either because:
/// - it is not on the correct rank
/// - it is occupied
/// - it is not behind an opponent's pawn
InvalidEnPassant = 32,
/// There is an impossible number of pieces.
///
/// Enforcing this enables to put an upper limit on the number of legal moves on any position,
/// allowing to reduce the size of [`Moves`].
TooMuchMaterial = 64,
/// The pawn that can be taken en passant is pinned diagonally to the playing king.
///
/// This can't happen on a legal position, as it would imply that the king could have be taken
/// on that move. Enforcing this makes it unnecessary to test for a discovery check on the
/// diagonal when taking en passant.
ImpossibleEnPassantPin = 128,
}
impl std::fmt::Display for IllegalPositionReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::MissingKing => "missing king",
Self::TooManyKings => "too many kings",
Self::HangingKing => "hanging king",
Self::PawnOnBackRank => "pawn on back rank",
Self::InvalidCastlingRights => "invalid castling rights",
Self::InvalidEnPassant => "invalid en passant",
Self::TooMuchMaterial => "too much material",
Self::ImpossibleEnPassantPin => "illegal en passant",
})
}
}