voronoi-go 1.0.1

Core rules and engine for Voronoi Go.
Documentation
//! What the board guarantees on top of the alive zone, and the check that says
//! so.
//!
//! [`Game::validate`] delegates to [`AliveZone::validate`](crate::AliveZone::validate)
//! and then adds the bookkeeping the board itself owns: that stones and dead
//! zones agree, that the turn counter and the status are the ones history
//! implies, and that the capture counts add up. It runs after every change
//! under `cfg(debug_assertions)`, on the same terms as the zone's own check —
//! a delta that leaves the board inconsistent is reported where it was applied
//! rather than several moves later.

use thiserror::Error;

use crate::alive_zone::ZoneError;
use crate::{Color, PerColor, StoneId};

use super::{Game, GameStatus};

/// Something the board guarantees, found not to hold.
///
/// Every variant is a bug in whatever last changed the board, not something a
/// caller can provoke with a legal move.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Error)]
pub enum BoardError {
    /// The alive zone underneath is corrupt.
    #[error("the alive zone is corrupt: {0}")]
    Zone(#[from] ZoneError),

    /// A stone is filed under an id that is not its own.
    #[error("stone {stone} is filed under id {id}")]
    Misfiled {
        /// The id it is filed under.
        id: StoneId,
        /// The id it carries.
        stone: StoneId,
    },

    /// A stone is in play with no dead zone carved for it, so its neighbours
    /// could be placed on top of it.
    #[error("stone {stone} is in play with no dead zone carved for it")]
    Uncarved {
        /// The stone.
        stone: StoneId,
    },

    /// A captured stone still carves a dead zone out of the playable area.
    #[error("captured stone {stone} still has a dead zone carved for it")]
    CapturedButCarved {
        /// The stone.
        stone: StoneId,
    },

    /// A stone is both in play and captured.
    #[error("stone {stone} is both in play and captured")]
    InPlayAndCaptured {
        /// The stone.
        stone: StoneId,
    },

    /// The turn counter and the committed history disagree, so the next stone
    /// would take an id another stone already has.
    #[error("the turn is {turn} but history holds {committed} committed turns")]
    TurnMismatch {
        /// What the board says.
        turn: u32,
        /// What history says.
        committed: usize,
    },

    /// The status is not the one the last two committed turns imply.
    #[error("the status is {recorded:?} but the committed turns imply {derived:?}")]
    StatusMismatch {
        /// What the board says.
        recorded: GameStatus,
        /// What history says.
        derived: GameStatus,
    },

    /// A capture count is not the number of stones history says that player
    /// took.
    #[error("{color:?} is credited with {recorded} captures but history holds {counted}")]
    CaptureCountMismatch {
        /// The player.
        color: Color,
        /// What the board says.
        recorded: u32,
        /// What history says.
        counted: u32,
    },
}

impl Game {
    /// Checks every invariant the board is supposed to hold.
    ///
    /// The alive zone first, then that stones and dead zones name each other
    /// one-for-one, then that the turn, the status and the capture counts are
    /// what history adds up to.
    ///
    /// # Errors
    ///
    /// The first invariant found not to hold. Any of them means an earlier
    /// change left the board inconsistent.
    pub fn validate(&self) -> Result<(), BoardError> {
        self.zone.validate()?;
        self.validate_bookkeeping()
    }

    /// Everything [`Game::validate`] checks except the alive zone itself.
    fn validate_bookkeeping(&self) -> Result<(), BoardError> {
        for (id, stone) in &self.stones {
            if stone.id != *id {
                return Err(BoardError::Misfiled {
                    id: *id,
                    stone: stone.id,
                });
            }
            if !self.zone.has_circle(*id) {
                return Err(BoardError::Uncarved { stone: *id });
            }
            if self.captured.contains_key(id) {
                return Err(BoardError::InPlayAndCaptured { stone: *id });
            }
        }

        for (id, stone) in &self.captured {
            if stone.id != *id {
                return Err(BoardError::Misfiled {
                    id: *id,
                    stone: stone.id,
                });
            }
            if self.zone.has_circle(*id) {
                return Err(BoardError::CapturedButCarved { stone: *id });
            }
        }

        let committed = self.history.committed_count();
        if self.turn as usize != committed {
            return Err(BoardError::TurnMismatch {
                turn: self.turn,
                committed,
            });
        }

        let derived = self.derived_status();
        if self.status != derived {
            return Err(BoardError::StatusMismatch {
                recorded: self.status,
                derived,
            });
        }

        let mut counted: PerColor<u32> = PerColor::new(0, 0);
        for entry in self.history.iter() {
            if let Some(stone) = entry.delta.new_stone {
                counted[stone.color] += entry.delta.captured_stone_ids.len() as u32;
            }
        }
        for color in Color::ALL {
            if counted[color] != self.captures[color] {
                return Err(BoardError::CaptureCountMismatch {
                    color,
                    recorded: self.captures[color],
                    counted: counted[color],
                });
            }
        }

        Ok(())
    }

    /// Panics if the board is inconsistent, in a debug build.
    ///
    /// Called at the end of every change. In a release build the check compiles
    /// away.
    ///
    /// The alive zone is left out: it checks itself after every one of its own
    /// mutating operations under the same `cfg`, so by the time a change gets
    /// here it has already been through that — several times, and it is the
    /// expensive half.
    pub(super) fn debug_validate(&self) {
        if cfg!(debug_assertions) {
            if let Err(error) = self.validate_bookkeeping() {
                panic!("the board is inconsistent: {error}");
            }
        }
    }
}