1
0
Fork 0

text records

This commit is contained in:
Paul-Nicolas Madelaine 2025-10-19 16:45:33 +02:00
parent 0e65b4f927
commit bbe65b74f4
6 changed files with 173 additions and 167 deletions

View file

@ -13,7 +13,7 @@ time that no illegal move is played, with no runtime checks and no potential pan
use eschac::prelude::*;
// read a position from a text record
let setup = "7k/4P1rp/5Q2/5p2/1Pp1bP2/8/r4K1P/6R1 w - -".parse::<Setup>()?;
let setup = Setup::from_text_record("7k/4P1rp/5Q2/5p2/1Pp1bP2/8/r4K1P/6R1 w - -")?;
let position = setup.validate()?;
// read a move in algebraic notation

View file

@ -57,7 +57,7 @@ impl Bitboard {
self.0 & (1 << square as u8) != 0
}
/// Returns the mirror of the bitboard (see [`Setup::mirror`]).
/// Returns the mirror of the bitboard (see [`Setup::mirror`](crate::setup::Setup::mirror)).
#[inline]
pub fn mirror(self) -> Bitboard {
let [a, b, c, d, e, f, g, h] = self.0.to_le_bytes();

View file

@ -27,7 +27,7 @@
//! use eschac::prelude::*;
//!
//! // read a position from a text record
//! let setup = "7k/4P1rp/5Q2/5p2/1Pp1bP2/8/r4K1P/6R1 w - -".parse::<Setup>()?;
//! let setup = Setup::from_text_record("7k/4P1rp/5Q2/5p2/1Pp1bP2/8/r4K1P/6R1 w - -")?;
//! let position = setup.validate()?;
//!
//! // read a move in algebraic notation

View file

@ -65,7 +65,7 @@ const MAX_LEGAL_MOVES: usize = 218;
impl std::fmt::Debug for Position {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
f.debug_tuple("Position")
.field(&self.as_setup().to_string())
.field(&self.as_setup().to_text_record())
.finish()
}
}
@ -96,12 +96,26 @@ impl Position {
/// ```
/// # use eschac::setup::Setup;
/// # |s: &str| -> Option<eschac::position::Position> {
/// s.parse::<Setup>().ok().and_then(|pos| pos.validate().ok())
/// Setup::from_text_record(s).ok().and_then(|pos| pos.validate().ok())
/// # };
/// ```
#[inline]
pub fn from_text_record(s: &str) -> Option<Self> {
s.parse::<Setup>().ok().and_then(|pos| pos.validate().ok())
Setup::from_text_record(s)
.ok()
.and_then(|pos| pos.validate().ok())
}
/// Returns the text record of the position.
///
/// This is a shortcut for:
/// ```
/// # |position: eschac::position::Position| {
/// position.as_setup().to_text_record()
/// # };
#[inline]
pub fn to_text_record(&self) -> String {
self.as_setup().to_text_record()
}
/// Returns all the legal moves on the position.

View file

@ -13,13 +13,13 @@ use crate::position::*;
/// 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.
/// ## Reading positions from text
///
/// 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.
/// track of those. For example, the starting position is recorded as
/// `rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq -`.
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Setup {
pub(crate) w: Bitboard,
@ -48,9 +48,15 @@ impl Setup {
}
}
/// Reads a position from a text record.
#[inline]
pub fn from_text_record(record: &str) -> Result<Self, ParseRecordError> {
Self::from_ascii_record(record.as_bytes())
}
/// Reads a position from an ascii record.
pub fn from_ascii(s: &[u8]) -> Result<Self, ParseSetupError> {
let mut s = s.iter().copied().peekable();
pub fn from_ascii_record(record: &[u8]) -> Result<Self, ParseRecordError> {
let mut s = record.iter().copied().peekable();
let mut setup = Setup::new();
(|| {
let mut accept_empty_square = true;
@ -92,7 +98,7 @@ impl Setup {
(file == 8).then_some(())?;
Some(())
})()
.ok_or(ParseSetupError::InvalidBoard)?;
.ok_or(ParseRecordError::InvalidBoard)?;
(|| {
match s.next()? {
b'w' => setup.set_turn(Color::White),
@ -101,7 +107,7 @@ impl Setup {
}
(s.next()? == b' ').then_some(())
})()
.ok_or(ParseSetupError::InvalidTurn)?;
.ok_or(ParseRecordError::InvalidTurn)?;
(|| {
if s.next_if_eq(&b'-').is_none() {
if s.next_if_eq(&b'K').is_some() {
@ -119,7 +125,7 @@ impl Setup {
}
(s.next()? == b' ').then_some(())
})()
.ok_or(ParseSetupError::InvalidCastlingRights)?;
.ok_or(ParseRecordError::InvalidCastlingRights)?;
(|| {
match s.next()? {
b'-' => (),
@ -130,10 +136,93 @@ impl Setup {
}
s.next().is_none().then_some(())
})()
.ok_or(ParseSetupError::InvalidEnPassantTargetSquare)?;
.ok_or(ParseRecordError::InvalidEnPassantTargetSquare)?;
Ok(setup)
}
/// Returns the text record of the position.
pub fn to_text_record(&self) -> String {
let mut record = String::with_capacity(81);
self.write_text_record(&mut record).unwrap();
record
}
/// Writes the text record of the position.
pub fn write_text_record<W>(&self, w: &mut W) -> std::fmt::Result
where
W: std::fmt::Write,
{
for rank in Rank::all().into_iter().rev() {
let mut count = 0;
for file in File::all() {
match self.get(Square::from_coords(file, rank)) {
Some(piece) => {
if count > 0 {
w.write_char(char::from_u32('0' as u32 + count).unwrap())?;
}
count = 0;
w.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 {
w.write_char(char::from_u32('0' as u32 + count).unwrap())?;
}
if rank != Rank::First {
w.write_char('/')?;
}
}
w.write_char(' ')?;
w.write_char(match self.turn {
Color::White => 'w',
Color::Black => 'b',
})?;
w.write_char(' ')?;
let mut no_castle_available = true;
if self.castling_rights(Color::White, CastlingSide::Short) {
w.write_char('K')?;
no_castle_available = false;
}
if self.castling_rights(Color::White, CastlingSide::Long) {
w.write_char('Q')?;
no_castle_available = false;
}
if self.castling_rights(Color::Black, CastlingSide::Short) {
w.write_char('k')?;
no_castle_available = false;
}
if self.castling_rights(Color::Black, CastlingSide::Long) {
w.write_char('q')?;
no_castle_available = false;
}
if no_castle_available {
w.write_char('-')?;
}
w.write_char(' ')?;
match self.en_passant.try_into_square() {
Some(sq) => {
w.write_str(sq.to_str())?;
}
None => {
w.write_char('-')?;
}
}
Ok(())
}
/// Returns the occupancy of a square.
#[inline]
pub fn get(&self, square: Square) -> Option<Piece> {
@ -428,117 +517,12 @@ impl Setup {
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()
f.debug_tuple("Setup")
.field(&self.to_text_record())
.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::from_coords(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.
@ -551,7 +535,7 @@ 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:")?;
write!(f, "`{}` is illegal:", setup.to_text_record())?;
let mut first = true;
for reason in self.reasons {
if !first {
@ -666,3 +650,26 @@ impl std::fmt::Display for IllegalPositionReason {
})
}
}
/// 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 ParseRecordError {
InvalidBoard,
InvalidTurn,
InvalidCastlingRights,
InvalidEnPassantTargetSquare,
}
impl std::fmt::Display for ParseRecordError {
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 ParseRecordError {}

View file

@ -13,13 +13,7 @@ static P6: &'static str = "r4rk1/1pp1qppp/p1np1n2/2b1p1B1/2B1P1b1/P1NP1N2/1PP1QP
fn recursive_check_aux(position: Position, depth: usize) {
assert_eq!(
position,
position
.as_setup()
.to_string()
.parse::<Setup>()
.unwrap()
.validate()
.unwrap(),
Position::from_text_record(&position.as_setup().to_text_record()).unwrap(),
);
if let Some(passed) = position.pass() {
@ -75,7 +69,7 @@ fn recursive_check_aux(position: Position, depth: usize) {
}
}
fn recursive_check(record: &str) {
recursive_check_aux(record.parse::<Setup>().unwrap().validate().unwrap(), 4);
recursive_check_aux(Position::from_text_record(record).unwrap(), 4);
}
#[test]
fn recursive_check_1() {
@ -104,34 +98,33 @@ fn recursive_check_6() {
#[test]
fn setup() {
assert_eq!(Position::new().as_setup().to_string(), P1);
assert_eq!(Setup::new().to_string(), "8/8/8/8/8/8/8/8 w - -");
assert_eq!(Position::new().as_setup().to_text_record(), P1);
assert_eq!(Setup::new().to_text_record(), "8/8/8/8/8/8/8/8 w - -");
assert_eq!(
"8/8/8/8/1Pp5/8/R1k5/K7 w - b3"
.parse::<Setup>()
Setup::from_text_record("8/8/8/8/1Pp5/8/R1k5/K7 w - b3")
.unwrap()
.to_string(),
.to_text_record(),
"8/8/8/8/1Pp5/8/R1k5/K7 w - b3",
);
for (record, err) in [
("", ParseSetupError::InvalidBoard),
(" w - -", ParseSetupError::InvalidBoard),
("8/8/8/8/8/8/8 w - -", ParseSetupError::InvalidBoard),
("1/1/1/1/1/1/1/1 w - -", ParseSetupError::InvalidBoard),
("", ParseRecordError::InvalidBoard),
(" w - -", ParseRecordError::InvalidBoard),
("8/8/8/8/8/8/8 w - -", ParseRecordError::InvalidBoard),
("1/1/1/1/1/1/1/1 w - -", ParseRecordError::InvalidBoard),
(
"44/44/44/44/44/44/44/44 w - -",
ParseSetupError::InvalidBoard,
ParseRecordError::InvalidBoard,
),
("8/8/8/8/8/8/8/8/8 w - -", ParseSetupError::InvalidBoard),
("p8/8/8/8/8/8/8/8 w - -", ParseSetupError::InvalidBoard),
("8/8/8/8/8/8/8/8 - - - ", ParseSetupError::InvalidTurn),
("8/8/8/8/8/8/8/8/8 w - -", ParseRecordError::InvalidBoard),
("p8/8/8/8/8/8/8/8 w - -", ParseRecordError::InvalidBoard),
("8/8/8/8/8/8/8/8 - - - ", ParseRecordError::InvalidTurn),
(
"8/8/8/8/8/8/8/8 w QQQQ -",
ParseSetupError::InvalidCastlingRights,
ParseRecordError::InvalidCastlingRights,
),
] {
assert_eq!(record.parse::<Setup>(), Err(err), "{record}");
assert_eq!(Setup::from_text_record(record), Err(err), "{record}");
}
for (record, reason) in [
(
@ -192,12 +185,12 @@ fn setup() {
),
] {
assert!(
record.parse::<Setup>().map(|record| record.to_string()) == Ok(record.to_string()),
Setup::from_text_record(record).map(|setup| setup.to_text_record())
== Ok(record.to_string()),
"{record}",
);
assert!(
record
.parse::<Setup>()
Setup::from_text_record(record)
.unwrap()
.validate()
.is_err_and(|e| e.reasons().contains(reason)),
@ -212,28 +205,24 @@ fn setup() {
"3kr3/8/8/8/4P3/8/8/4K3 b - e3",
"8/8/8/3k4/3P4/8/8/3RK3 b - d3",
] {
assert!(record.parse::<Setup>().is_ok(), "{record}");
assert!(Setup::from_text_record(record).is_ok(), "{record}");
}
}
#[test]
fn mirror() {
assert_eq!(Position::new().pass(), Some(Position::new().mirror()));
let position = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b Kq e3"
.parse::<Setup>()
.unwrap()
.validate()
let position =
Position::from_text_record("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b Kq e3")
.unwrap();
let mirror = "rnbqkbnr/pppp1ppp/8/4p3/8/8/PPPPPPPP/RNBQKBNR w Qk e6"
.parse::<Setup>()
.unwrap()
.validate()
let mirror =
Position::from_text_record("rnbqkbnr/pppp1ppp/8/4p3/8/8/PPPPPPPP/RNBQKBNR w Qk e6")
.unwrap();
assert_eq!(mirror, position.mirror());
}
fn perft_aux(record: &str, tests: &[u128]) {
let position = record.parse::<Setup>().unwrap().validate().unwrap();
let position = Position::from_text_record(record).unwrap();
for (depth, value) in tests.iter().copied().enumerate() {
assert_eq!(
position.perft(depth),
@ -272,11 +261,7 @@ fn perft_6() {
#[test]
fn san() {
let position = "8/2KN1p2/5p2/3N1B1k/5PNp/7P/7P/8 w - -"
.parse::<Setup>()
.unwrap()
.validate()
.unwrap();
let position = Position::from_text_record("8/2KN1p2/5p2/3N1B1k/5PNp/7P/7P/8 w - -").unwrap();
let san1 = "N7xf6#".parse::<San>().unwrap();
let m1 = san1.to_move(&position).unwrap();
let san2 = "N5xf6#".parse::<San>().unwrap();