sashite-qi 0.1.0

Qi: an immutable, format-agnostic position model for two-player board games (chess, shogi, xiangqi, and beyond).
Documentation
//! The generic position type, [`Qi`].

use alloc::collections::btree_map::{BTreeMap, Entry};
use alloc::vec::Vec;
use core::cmp::Ordering;
use core::iter;

use crate::error::Error;
use crate::limits::{MAX_DIMENSIONS, MAX_DIMENSION_SIZE, MAX_SQUARE_COUNT};
use crate::player::Player;

/// An immutable position: a board, two hands, a style per player, and the turn.
///
/// `Qi` is generic over the piece type `P` and the style type `S`, so it is
/// independent of any notation. The board is a flat vector in row-major order;
/// each hand is a `piece → count` map. Transformations consume the position and
/// return a new one (moving, not cloning, the storage).
///
/// # Examples
///
/// ```
/// use sashite_qi::{Player, Qi};
///
/// // An 8×8 board with string styles; any `P`/`S` types work.
/// let pos = Qi::new(&[8, 8], "C", "c")?
///     .board_diff([(4, Some("K")), (60, Some("k"))])? // kings
///     .toggle();
///
/// assert_eq!(pos.square_count(), 64);
/// assert_eq!(pos.piece_count(), 2);
/// assert_eq!(pos.turn(), Player::Second);
/// assert_eq!(pos.square(4), Some(&"K"));
/// # Ok::<(), sashite_qi::Error>(())
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Qi<P, S> {
    dims: [usize; MAX_DIMENSIONS],
    ndim: usize,
    board: Vec<Option<P>>,
    first_hand: BTreeMap<P, usize>,
    second_hand: BTreeMap<P, usize>,
    first_style: S,
    second_style: S,
    turn: Player,
    board_pieces: usize,
    hand_pieces: usize,
}

impl<P, S> Qi<P, S> {
    /// Creates a position with an empty board, empty hands, and the first player
    /// to move.
    ///
    /// `shape` lists the dimension sizes (e.g. `&[8, 8]`), outermost first.
    ///
    /// # Errors
    ///
    /// Returns an [`Error`] if the shape is empty, has more than
    /// [`MAX_DIMENSIONS`] dimensions, has a dimension outside `1..=`[`MAX_DIMENSION_SIZE`],
    /// or describes more than [`MAX_SQUARE_COUNT`] squares.
    pub fn new(shape: &[usize], first_style: S, second_style: S) -> Result<Self, Error> {
        if shape.is_empty() {
            return Err(Error::EmptyShape);
        }
        if shape.len() > MAX_DIMENSIONS {
            return Err(Error::TooManyDimensions);
        }

        let mut dims = [0usize; MAX_DIMENSIONS];
        let mut total: usize = 1;
        for (slot, &size) in dims.iter_mut().zip(shape.iter()) {
            if size == 0 {
                return Err(Error::DimensionTooSmall);
            }
            if size > MAX_DIMENSION_SIZE {
                return Err(Error::DimensionTooLarge);
            }
            *slot = size;
            total = total.checked_mul(size).ok_or(Error::TooManySquares)?;
        }
        if total > MAX_SQUARE_COUNT {
            return Err(Error::TooManySquares);
        }

        let board = iter::repeat_with(|| None).take(total).collect();

        Ok(Self {
            dims,
            ndim: shape.len(),
            board,
            first_hand: BTreeMap::new(),
            second_hand: BTreeMap::new(),
            first_style,
            second_style,
            turn: Player::First,
            board_pieces: 0,
            hand_pieces: 0,
        })
    }

    /// Returns a position with the given board squares replaced.
    ///
    /// Each change is `(flat_index, square)`, where `square` is `Some(piece)` or
    /// `None` (empty). Indices are 0-based, row-major.
    ///
    /// # Errors
    ///
    /// Returns [`Error::IndexOutOfRange`] if an index is not a square on this
    /// board, or [`Error::TooManyPieces`] if the result would hold more pieces
    /// than squares.
    pub fn board_diff(
        mut self,
        changes: impl IntoIterator<Item = (usize, Option<P>)>,
    ) -> Result<Self, Error> {
        let total = self.board.len();
        for (index, square) in changes {
            if index >= total {
                return Err(Error::IndexOutOfRange);
            }
            let was_occupied = self.board[index].is_some();
            let now_occupied = square.is_some();
            self.board[index] = square;
            match (was_occupied, now_occupied) {
                (false, true) => self.board_pieces += 1,
                (true, false) => self.board_pieces -= 1,
                _ => {}
            }
        }
        self.check_cardinality()?;
        Ok(self)
    }

    /// Returns a position with the active player swapped.
    #[must_use]
    pub fn toggle(mut self) -> Self {
        self.turn = self.turn.flip();
        self
    }

    /// Returns a position with the active player set explicitly.
    #[must_use]
    pub fn with_turn(mut self, turn: Player) -> Self {
        self.turn = turn;
        self
    }

    /// The dimension sizes, outermost first (e.g. `&[8, 8]`).
    #[must_use]
    pub fn shape(&self) -> &[usize] {
        &self.dims[..self.ndim]
    }

    /// The number of board dimensions.
    #[must_use]
    pub const fn dimension_count(&self) -> usize {
        self.ndim
    }

    /// The total number of squares on the board.
    #[must_use]
    pub fn square_count(&self) -> usize {
        self.board.len()
    }

    /// The total number of pieces, on the board and in both hands.
    #[must_use]
    pub const fn piece_count(&self) -> usize {
        self.board_pieces + self.hand_pieces
    }

    /// The number of pieces on the board.
    #[must_use]
    pub const fn board_piece_count(&self) -> usize {
        self.board_pieces
    }

    /// The number of pieces held across both hands.
    #[must_use]
    pub const fn hand_piece_count(&self) -> usize {
        self.hand_pieces
    }

    /// The active player.
    #[must_use]
    pub const fn turn(&self) -> Player {
        self.turn
    }

    /// The first player's style.
    #[must_use]
    pub const fn first_style(&self) -> &S {
        &self.first_style
    }

    /// The second player's style.
    #[must_use]
    pub const fn second_style(&self) -> &S {
        &self.second_style
    }

    /// The board as a flat slice in row-major order (`None` is an empty square).
    #[must_use]
    pub fn board(&self) -> &[Option<P>] {
        &self.board
    }

    /// The piece at a flat index, or `None` if the square is empty or invalid.
    #[must_use]
    pub fn square(&self, index: usize) -> Option<&P> {
        self.board.get(index).and_then(Option::as_ref)
    }

    /// Iterates the First Player Hand as `(piece, count)` pairs.
    pub fn first_hand(&self) -> impl Iterator<Item = (&P, usize)> {
        self.first_hand.iter().map(|(piece, &count)| (piece, count))
    }

    /// Iterates the Second Player Hand as `(piece, count)` pairs.
    pub fn second_hand(&self) -> impl Iterator<Item = (&P, usize)> {
        self.second_hand
            .iter()
            .map(|(piece, &count)| (piece, count))
    }

    /// Checks that the total piece count does not exceed the square count.
    fn check_cardinality(&self) -> Result<(), Error> {
        if self.board_pieces.saturating_add(self.hand_pieces) > self.board.len() {
            return Err(Error::TooManyPieces);
        }
        Ok(())
    }
}

impl<P: Ord, S> Qi<P, S> {
    /// Returns a position with the First Player Hand adjusted.
    ///
    /// Each change is `(piece, delta)`: a positive delta adds copies, a negative
    /// delta removes them, zero is a no-op.
    ///
    /// # Errors
    ///
    /// Returns [`Error::HandUnderflow`] when removing more copies than are held,
    /// or [`Error::TooManyPieces`] if the result would exceed the board.
    pub fn first_hand_diff(
        mut self,
        changes: impl IntoIterator<Item = (P, i32)>,
    ) -> Result<Self, Error> {
        apply_hand_diff(&mut self.first_hand, &mut self.hand_pieces, changes)?;
        self.check_cardinality()?;
        Ok(self)
    }

    /// Returns a position with the Second Player Hand adjusted.
    ///
    /// See [`first_hand_diff`](Qi::first_hand_diff) for the change format.
    ///
    /// # Errors
    ///
    /// Returns [`Error::HandUnderflow`] when removing more copies than are held,
    /// or [`Error::TooManyPieces`] if the result would exceed the board.
    pub fn second_hand_diff(
        mut self,
        changes: impl IntoIterator<Item = (P, i32)>,
    ) -> Result<Self, Error> {
        apply_hand_diff(&mut self.second_hand, &mut self.hand_pieces, changes)?;
        self.check_cardinality()?;
        Ok(self)
    }

    /// The number of copies of `piece` in the First Player Hand (0 if absent).
    #[must_use]
    pub fn first_hand_count(&self, piece: &P) -> usize {
        self.first_hand.get(piece).copied().unwrap_or(0)
    }

    /// The number of copies of `piece` in the Second Player Hand (0 if absent).
    #[must_use]
    pub fn second_hand_count(&self, piece: &P) -> usize {
        self.second_hand.get(piece).copied().unwrap_or(0)
    }
}

/// Applies a sequence of hand deltas to one hand, updating the running total.
fn apply_hand_diff<P: Ord>(
    hand: &mut BTreeMap<P, usize>,
    hand_pieces: &mut usize,
    changes: impl IntoIterator<Item = (P, i32)>,
) -> Result<(), Error> {
    for (piece, delta) in changes {
        match delta.cmp(&0) {
            Ordering::Equal => {} // no-op
            Ordering::Greater => {
                let added = magnitude(delta);
                let slot = hand.entry(piece).or_insert(0);
                *slot = slot.saturating_add(added);
                *hand_pieces = hand_pieces.saturating_add(added);
            }
            Ordering::Less => {
                let removed = magnitude(delta);
                match hand.entry(piece) {
                    Entry::Occupied(mut entry) => {
                        let current = *entry.get();
                        if removed > current {
                            return Err(Error::HandUnderflow);
                        }
                        let remaining = current - removed;
                        if remaining == 0 {
                            entry.remove();
                        } else {
                            *entry.get_mut() = remaining;
                        }
                        *hand_pieces -= removed;
                    }
                    Entry::Vacant(_) => return Err(Error::HandUnderflow),
                }
            }
        }
    }
    Ok(())
}

/// The magnitude of a delta as a `usize`, handling `i32::MIN` without overflow.
fn magnitude(delta: i32) -> usize {
    usize::try_from(delta.unsigned_abs()).unwrap_or(usize::MAX)
}