rsshogi 1.2.4

Reusable Rust shogi primitives for board state, move generation, legality, and record parsing.
Documentation
use std::collections::HashMap;
use std::io::Write;

use crate::book::{BookDatabase, BookDatabaseEntry, BookError};

const HEADER: &str = "#YANEURAOU-DB2016 1.00";

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct YaneuraOuDb2016WriteOptions {
    preserve_move_order: bool,
    fixed_ply: Option<u32>,
    omit_ply: bool,
    keep_last_on_duplicate: bool,
    emit_noe: bool,
}

impl YaneuraOuDb2016WriteOptions {
    #[must_use]
    pub const fn new() -> Self {
        Self {
            preserve_move_order: false,
            fixed_ply: None,
            omit_ply: false,
            keep_last_on_duplicate: false,
            emit_noe: false,
        }
    }

    #[must_use]
    pub const fn with_preserved_move_order(mut self) -> Self {
        self.preserve_move_order = true;
        self
    }

    #[must_use]
    pub const fn with_fixed_ply(mut self, ply: u32) -> Self {
        self.fixed_ply = Some(ply);
        self
    }

    #[must_use]
    pub const fn with_omitted_ply(mut self) -> Self {
        self.omit_ply = true;
        self
    }

    #[must_use]
    pub const fn with_keep_last_on_duplicate(mut self) -> Self {
        self.keep_last_on_duplicate = true;
        self
    }

    #[must_use]
    pub const fn with_noe(mut self, emit: bool) -> Self {
        self.emit_noe = emit;
        self
    }
}

impl Default for YaneuraOuDb2016WriteOptions {
    fn default() -> Self {
        Self::new()
    }
}

impl BookDatabase {
    pub fn write_yaneuraou_db2016(
        &self,
        w: &mut impl Write,
        options: &YaneuraOuDb2016WriteOptions,
    ) -> Result<(), BookError> {
        w.write_all(self.to_yaneuraou_db2016_string(options)?.as_bytes())?;
        Ok(())
    }

    pub fn to_yaneuraou_db2016_string(
        &self,
        options: &YaneuraOuDb2016WriteOptions,
    ) -> Result<String, BookError> {
        let mut by_sfen: HashMap<&str, &BookDatabaseEntry> = HashMap::new();
        for entry in self.entries() {
            let key = entry.position().sfen();
            if by_sfen.contains_key(key) && !options.keep_last_on_duplicate {
                return Err(BookError::InvalidData(format!("duplicate DB2016 position: {key}")));
            }
            by_sfen.insert(key, entry);
        }

        let mut entries: Vec<_> = by_sfen.into_values().collect();
        entries.sort_by(|left, right| {
            left.position().sfen().as_bytes().cmp(right.position().sfen().as_bytes())
        });

        let mut output = String::new();
        output.push_str(HEADER);
        output.push('\n');
        if options.emit_noe {
            output.push_str(&format!("# NOE:{}\n", entries.len()));
        }

        for entry in entries {
            write_entry(&mut output, entry, options)?;
        }
        Ok(output)
    }
}

fn write_entry(
    output: &mut String,
    entry: &BookDatabaseEntry,
    options: &YaneuraOuDb2016WriteOptions,
) -> Result<(), BookError> {
    let position = entry.position();
    let mut fields = position.sfen().split_ascii_whitespace();
    let board = fields.next().ok_or(BookError::InvalidData("missing normalized board".into()))?;
    let side = fields.next().ok_or(BookError::InvalidData("missing normalized side".into()))?;
    let hands = fields.next().ok_or(BookError::InvalidData("missing normalized hands".into()))?;
    output.push_str("sfen ");
    output.push_str(board);
    output.push(' ');
    output.push_str(side);
    output.push(' ');
    output.push_str(hands);
    if !options.omit_ply {
        let ply = options
            .fixed_ply
            .or(position.original_ply())
            .or_else(|| entry.metadata().yaneuraou().map(|metadata| metadata.min_ply()))
            .unwrap_or(1);
        if ply != 0 {
            output.push(' ');
            output.push_str(&ply.to_string());
        }
    }
    output.push('\n');

    if let Some(metadata) = entry.metadata().yaneuraou() {
        write_comments(output, metadata.comment());
    }

    let mut candidates: Vec<_> = entry.candidates().iter().collect();
    if !options.preserve_move_order {
        candidates.sort_by(|left, right| match (left.score(), right.score()) {
            (Some(left), Some(right)) => right.cmp(&left),
            (Some(_), None) => std::cmp::Ordering::Less,
            (None, Some(_)) => std::cmp::Ordering::Greater,
            (None, None) => std::cmp::Ordering::Equal,
        });
    }

    for candidate in candidates {
        if !is_valid_db2016_move(candidate.mv()) {
            return Err(BookError::InvalidData(format!(
                "invalid DB2016 move: {:#06x}",
                candidate.mv().raw()
            )));
        }
        if candidate.ponder().is_some_and(|ponder| !is_valid_db2016_move(ponder)) {
            return Err(BookError::InvalidData("invalid DB2016 ponder move".into()));
        }
        output.push_str(&candidate.mv().to_usi());
        output.push(' ');
        output.push_str(&candidate.ponder().map_or_else(|| "none".to_string(), |mv| mv.to_usi()));
        output.push(' ');
        write_optional(output, candidate.score());
        output.push(' ');
        write_optional(output, candidate.depth());
        if let Some(count) = candidate.count() {
            output.push(' ');
            output.push_str(&count.to_string());
        }
        output.push('\n');
        if let Some(metadata) = candidate.metadata().yaneuraou() {
            write_comments(output, metadata.comment());
        }
    }
    Ok(())
}

fn write_optional<T: std::fmt::Display>(output: &mut String, value: Option<T>) {
    match value {
        Some(value) => output.push_str(&value.to_string()),
        None => output.push_str("none"),
    }
}

fn write_comments(output: &mut String, comment: &str) {
    if comment.is_empty() {
        return;
    }
    for line in comment.split('\n') {
        output.push_str("# ");
        output.push_str(line);
        output.push('\n');
    }
}

fn is_valid_db2016_move(mv: crate::types::Move) -> bool {
    if !mv.is_normal() || mv.to_sq().raw() < 0 || mv.to_sq().raw() >= 81 {
        return false;
    }
    if mv.is_drop() {
        !mv.is_promotion() && matches!((mv.raw() >> 7) & 0x7f, 1..=7)
    } else {
        mv.from_sq().raw() >= 0 && mv.from_sq().raw() < 81 && mv.from_sq() != mv.to_sq()
    }
}