mod forced_eyes;
mod queries;
#[cfg(feature = "svg")]
#[cfg_attr(docsrs, doc(cfg(feature = "svg")))]
mod svg;
mod validate;
use std::collections::BTreeMap;
use thiserror::Error;
use crate::clipping::{ClippingGraph, Segment, ShapeId};
use crate::geometry::{Circle, point_is_on_board};
use crate::{EPSILON, Point, STONE_DIAMETER, StoneId};
use forced_eyes::ForcedEyes;
pub use validate::ZoneError;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Error)]
pub enum DeadZoneError {
#[error("stone {stone}'s dead zone has already been carved out of the alive zone")]
AlreadyCarved {
stone: StoneId,
},
#[error("stone {stone} has no dead zone carved out of the alive zone")]
NotCarved {
stone: StoneId,
},
}
#[derive(Clone, Debug)]
pub struct AliveZone {
graph: ClippingGraph,
dead_zones: BTreeMap<StoneId, ShapeId>,
forced_eyes: ForcedEyes,
temp_circles: Vec<ShapeId>,
}
impl AliveZone {
#[must_use]
pub fn new(board_size: f64) -> Self {
let zone = Self {
graph: ClippingGraph::new(board_size),
dead_zones: BTreeMap::new(),
forced_eyes: ForcedEyes::new(),
temp_circles: Vec::new(),
};
zone.debug_validate();
zone
}
#[must_use]
pub const fn board_size(&self) -> f64 {
self.graph.board_size()
}
pub fn remove_circle(&mut self, stone: StoneId, center: Point) -> Result<(), DeadZoneError> {
if self.dead_zones.contains_key(&stone) {
return Err(DeadZoneError::AlreadyCarved { stone });
}
let zone = self.carve(center);
self.dead_zones.insert(stone, zone);
self.debug_validate();
Ok(())
}
fn carve(&mut self, center: Point) -> ShapeId {
self.compound(|zone| {
let targets: Vec<ShapeId> = zone.graph.shape_ids().collect();
let dead_zone = zone.graph.add_dead_zone(center);
let circle = Circle::new(center, STONE_DIAMETER);
for target in targets {
let _ = zone.clip(target, dead_zone, circle);
}
dead_zone
})
}
fn compound<T>(&mut self, f: impl FnOnce(&mut Self) -> T) -> T {
struct Resume<'zone> {
zone: &'zone mut AliveZone,
}
impl Drop for Resume<'_> {
fn drop(&mut self) {
self.zone.graph.resume_validation();
}
}
self.graph.defer_validation();
let guard = Resume { zone: self };
f(&mut *guard.zone)
}
fn clip(&mut self, target: ShapeId, zone: ShapeId, circle: Circle) -> Option<()> {
let crossing = self.graph.intersect(target, circle)?;
let target_covered = self.graph.insert_or_get_existing(target, crossing.entry)?;
let zone_uncovered = self.graph.insert_or_get_existing(zone, crossing.entry)?;
let target_uncovered = self.graph.insert_or_get_existing(target, crossing.exit)?;
let zone_covered = self.graph.insert_or_get_existing(zone, crossing.exit)?;
self.graph
.add_overlapping(target_covered, target_uncovered, zone);
self.graph
.add_overlapping(zone_covered, zone_uncovered, target);
Some(())
}
pub fn reclaim_circle(&mut self, stone: StoneId) -> Result<(), DeadZoneError> {
let zone = self
.dead_zones
.remove(&stone)
.ok_or(DeadZoneError::NotCarved { stone })?;
self.uncarve(zone);
self.debug_validate();
Ok(())
}
fn uncarve(&mut self, dead_zone: ShapeId) {
self.compound(|zone| {
let others: Vec<ShapeId> = zone
.graph
.shape_ids()
.filter(|id| *id != dead_zone)
.collect();
for other in others {
zone.graph.remove_overlapping(other, dead_zone);
}
for node in zone.graph.node_ids(dead_zone) {
let Some(point) = zone.graph.segment(node).map(Segment::point) else {
continue;
};
zone.graph.delete_segment(node);
if let Some(orphan) = zone.graph.sole_segment_at(point) {
zone.graph.delete_segment(orphan);
}
}
let _ = zone.graph.remove_shape(dead_zone);
});
}
#[must_use]
pub fn has_circle(&self, stone: StoneId) -> bool {
self.dead_zones.contains_key(&stone)
}
pub fn with_temp_circle<T>(&mut self, center: Point, f: impl FnOnce(&mut Self) -> T) -> T {
struct Restore<'zone> {
zone: &'zone mut AliveZone,
}
impl Drop for Restore<'_> {
fn drop(&mut self) {
if let Some(temp) = self.zone.temp_circles.pop() {
self.zone.uncarve(temp);
self.zone.debug_validate();
}
}
}
let temp = self.carve(center);
self.temp_circles.push(temp);
self.debug_validate();
let guard = Restore { zone: self };
f(&mut *guard.zone)
}
pub fn add_forced_eye(&mut self, point: Point) {
self.forced_eyes.insert(point);
self.debug_validate();
}
pub fn remove_forced_eye(&mut self, point: Point) -> bool {
let removed = self.forced_eyes.take(point).is_some();
self.debug_validate();
removed
}
#[must_use]
pub fn has_forced_eye(&self, point: Point) -> bool {
self.forced_eyes.contains(point)
}
pub fn forced_eyes(&self) -> impl Iterator<Item = Point> + '_ {
self.forced_eyes.iter()
}
#[must_use]
pub fn forced_eye_count(&self) -> usize {
self.forced_eyes.len()
}
pub fn remove_forced_eyes_near_point(&mut self, center: Point) -> Vec<Point> {
if let Some(exact) = self.forced_eyes.take(center) {
self.debug_validate();
return vec![exact];
}
let removed: Vec<Point> = self
.forced_eyes
.iter()
.filter(|eye| eye.distance(center) <= STONE_DIAMETER)
.collect();
for eye in &removed {
self.forced_eyes.take(*eye);
}
self.debug_validate();
removed
}
#[must_use]
pub fn contains(&self, point: Point) -> bool {
self.deepest(point) <= 0.0
}
#[must_use]
pub fn is_placeable(&self, point: Point) -> bool {
self.deepest(point) <= EPSILON
}
fn deepest(&self, point: Point) -> f64 {
if !point_is_on_board(point, self.board_size()) {
return f64::INFINITY;
}
if self.forced_eyes.contains(point) {
return f64::NEG_INFINITY;
}
self.graph
.shapes()
.map(|(_, shape)| shape.kind().depth(point))
.fold(f64::NEG_INFINITY, f64::max)
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use std::collections::BTreeSet;
use super::{AliveZone, DeadZoneError};
use crate::clipping::{Segment, Shape, ShapeId};
use crate::{Point, STONE_DIAMETER, StoneId};
const BOARD: f64 = 20.0;
fn p(x: f64, y: f64) -> Point {
Point::new(x, y)
}
fn s(id: u32) -> StoneId {
StoneId::new(id)
}
fn carve_all(board: f64, centers: &[Point]) -> AliveZone {
let mut zone = AliveZone::new(board);
for (index, center) in centers.iter().enumerate() {
zone.remove_circle(s(index as u32), *center).unwrap();
assert_eq!(zone.validate(), Ok(()));
}
zone
}
fn probe_grid(zone: &AliveZone, steps: usize) -> Vec<bool> {
let size = zone.board_size();
let coordinate = |n: usize| size * (n as f64 + 1.0) / (steps as f64 + 1.0);
let mut probes = Vec::with_capacity(steps * steps);
for row in 0..steps {
for column in 0..steps {
probes.push(zone.contains(p(coordinate(row), coordinate(column))));
}
}
probes
}
#[test]
fn a_new_zone_is_the_board_and_nothing_else() {
let zone = AliveZone::new(BOARD);
assert_eq!(zone.validate(), Ok(()));
assert_eq!(zone.board_size().to_bits(), BOARD.to_bits());
assert_eq!(zone.graph.shape_ids().count(), 4);
assert_eq!(zone.forced_eye_count(), 0);
}
#[test]
fn a_single_dead_zone_is_carved_and_named() {
let zone = carve_all(BOARD, &[p(10.0, 10.0)]);
assert!(zone.has_circle(s(0)));
assert!(!zone.has_circle(s(1)));
assert_eq!(zone.graph.shape_ids().count(), 5);
}
#[test]
fn dead_zones_that_do_not_touch_leave_each_other_whole() {
let zone = carve_all(
BOARD,
&[p(5.0, 5.0), p(15.0, 5.0), p(5.0, 15.0), p(15.0, 15.0)],
);
for stone in 0..4 {
let shape = *zone.dead_zones.get(&s(stone)).unwrap();
assert_eq!(zone.graph.shape(shape).unwrap().count(), 0);
}
}
#[test]
fn overlapping_dead_zones_split_each_other() {
let zone = carve_all(BOARD, &[p(10.0, 10.0), p(12.0, 10.0)]);
for stone in 0..2 {
let shape = *zone.dead_zones.get(&s(stone)).unwrap();
assert_eq!(zone.graph.shape(shape).unwrap().count(), 2);
assert_eq!(
zone.graph
.segments(shape)
.filter(|id| zone.graph.is_active(*id))
.count(),
1
);
}
}
#[test]
fn a_dead_zone_surrounded_on_every_side_has_nothing_visible_left() {
let spacing = STONE_DIAMETER * 1.5;
let mut centers: Vec<Point> = (0..6)
.map(|step| {
let angle = f64::from(step) * core::f64::consts::TAU / 6.0;
p(10.0 + spacing * angle.cos(), 10.0 + spacing * angle.sin())
})
.collect();
centers.push(p(10.0, 10.0));
let zone = carve_all(BOARD, ¢ers);
let middle = *zone.dead_zones.get(&s(6)).unwrap();
assert!(
zone.graph
.segments(middle)
.all(|id| !zone.graph.is_active(id))
);
assert!(!zone.contains(p(10.0, 10.0)));
}
#[test]
fn a_dead_zone_against_an_edge_splits_the_edge() {
let zone = carve_all(BOARD, &[p(2.0, 10.0)]);
let left = zone.graph.shape_ids().next().unwrap();
assert_eq!(zone.graph.shape(left).unwrap().count(), 6);
assert!(
zone.graph
.segments(left)
.any(|id| !zone.graph.is_active(id))
);
}
#[test]
fn a_dead_zone_at_a_corner_splits_both_edges() {
let zone = carve_all(BOARD, &[p(2.0, 2.0)]);
let shape = *zone.dead_zones.get(&s(0)).unwrap();
assert_eq!(zone.graph.shape(shape).unwrap().count(), 4);
}
#[test]
fn carving_the_same_stone_twice_is_refused() {
let mut zone = carve_all(BOARD, &[p(10.0, 10.0)]);
assert_eq!(
zone.remove_circle(s(0), p(4.0, 4.0)),
Err(DeadZoneError::AlreadyCarved { stone: s(0) })
);
assert_eq!(zone.graph.shape_ids().count(), 5);
assert_eq!(zone.validate(), Ok(()));
}
#[test]
fn a_progressive_run_of_overlaps_stays_consistent() {
let centers: Vec<Point> = (0..5).map(|i| p(5.0 + f64::from(i) * 1.5, 10.0)).collect();
let zone = carve_all(BOARD, ¢ers);
assert_eq!(zone.validate(), Ok(()));
}
#[test]
fn reclaiming_a_stone_that_has_no_dead_zone_is_refused() {
let mut zone = AliveZone::new(BOARD);
assert_eq!(
zone.reclaim_circle(s(3)),
Err(DeadZoneError::NotCarved { stone: s(3) })
);
}
#[test]
fn reclaiming_takes_the_shape_and_its_crossings_away() {
let mut zone = carve_all(BOARD, &[p(10.0, 10.0), p(12.0, 10.0)]);
let reclaimed = *zone.dead_zones.get(&s(1)).unwrap();
let survivor = *zone.dead_zones.get(&s(0)).unwrap();
zone.reclaim_circle(s(1)).unwrap();
assert!(zone.graph.shape(reclaimed).is_none());
assert!(!zone.has_circle(s(1)));
assert_eq!(zone.graph.shape(survivor).unwrap().count(), 0);
assert_eq!(zone.validate(), Ok(()));
}
#[test]
fn reclaiming_gives_the_area_back() {
let mut zone = carve_all(BOARD, &[p(10.0, 10.0)]);
assert!(!zone.contains(p(10.5, 10.0)));
zone.reclaim_circle(s(0)).unwrap();
assert!(zone.contains(p(10.5, 10.0)));
}
#[test]
fn reclaiming_a_surrounded_dead_zone_restores_its_neighbours() {
let spacing = STONE_DIAMETER * 1.5;
let mut zone = carve_all(
BOARD,
&[
p(10.0 + spacing, 10.0),
p(10.0 - spacing, 10.0),
p(10.0, 10.0 + spacing),
p(10.0, 10.0 - spacing),
p(10.0, 10.0),
],
);
zone.reclaim_circle(s(4)).unwrap();
assert_eq!(zone.validate(), Ok(()));
assert!(zone.contains(p(10.0, 10.0)));
}
#[test]
fn carving_reclaiming_and_carving_again_reproduces_the_structure() {
let mut zone = carve_all(BOARD, &[p(6.0, 6.0), p(8.5, 7.0), p(7.0, 9.0)]);
let carved = zone.fingerprint();
let probes = probe_grid(&zone, 7);
zone.reclaim_circle(s(1)).unwrap();
assert_eq!(zone.validate(), Ok(()));
assert_ne!(zone.fingerprint(), carved);
zone.remove_circle(s(1), p(8.5, 7.0)).unwrap();
assert_eq!(zone.fingerprint(), carved);
assert_eq!(probe_grid(&zone, 7), probes);
assert_eq!(zone.validate(), Ok(()));
}
#[test]
fn a_dead_zone_against_an_edge_round_trips_too() {
let mut zone = carve_all(BOARD, &[p(2.0, 10.0), p(2.0, 13.0)]);
let carved = zone.fingerprint();
zone.reclaim_circle(s(1)).unwrap();
zone.remove_circle(s(1), p(2.0, 13.0)).unwrap();
assert_eq!(zone.fingerprint(), carved);
}
#[test]
fn unwinding_every_dead_zone_restores_the_empty_board() {
let centers = [p(5.0, 5.0), p(7.0, 6.0), p(6.0, 8.0), p(9.0, 9.0)];
let empty = AliveZone::new(BOARD).fingerprint();
let empty_probes = probe_grid(&AliveZone::new(BOARD), 7);
let mut zone = carve_all(BOARD, ¢ers);
for stone in (0..centers.len() as u32).rev() {
zone.reclaim_circle(s(stone)).unwrap();
assert_eq!(zone.validate(), Ok(()));
}
assert_eq!(zone.fingerprint(), empty);
assert_eq!(probe_grid(&zone, 7), empty_probes);
assert_eq!(zone.graph.segment_count(), 16);
}
#[test]
fn reclaiming_out_of_order_restores_the_empty_board_too() {
let centers = [p(5.0, 5.0), p(7.0, 6.0), p(6.0, 8.0), p(9.0, 9.0)];
let empty = AliveZone::new(BOARD).fingerprint();
let mut zone = carve_all(BOARD, ¢ers);
for stone in [1_u32, 3, 0, 2] {
zone.reclaim_circle(s(stone)).unwrap();
}
assert_eq!(zone.fingerprint(), empty);
}
#[test]
fn a_temporary_circle_is_there_inside_the_closure() {
let mut zone = carve_all(BOARD, &[p(5.0, 5.0)]);
assert!(zone.contains(p(12.0, 12.0)));
let seen = zone.with_temp_circle(p(12.0, 12.0), |zone| {
assert_eq!(zone.validate(), Ok(()));
(zone.contains(p(12.0, 12.0)), zone.contains(p(12.5, 12.0)))
});
assert_eq!(
seen,
(false, false),
"the temporary dead zone was in the way"
);
assert!(zone.contains(p(12.0, 12.0)), "and is not any more");
}
#[test]
fn a_temporary_circle_leaves_the_zone_bit_identical() {
let mut zone = carve_all(BOARD, &[p(6.0, 6.0), p(8.5, 7.0), p(7.0, 9.0)]);
let before = zone.fingerprint();
let probes = probe_grid(&zone, 9);
zone.with_temp_circle(p(7.5, 7.5), |zone| {
assert_eq!(zone.validate(), Ok(()));
});
assert_eq!(zone.fingerprint(), before);
assert_eq!(probe_grid(&zone, 9), probes);
assert_eq!(zone.validate(), Ok(()));
}
#[test]
fn a_temporary_circle_is_restored_even_when_the_closure_panics() {
let mut zone = carve_all(BOARD, &[p(6.0, 6.0), p(8.5, 7.0)]);
let before = zone.fingerprint();
let unwound = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
zone.with_temp_circle(p(7.5, 7.5), |_| panic!("the closure gave up"));
}));
assert!(unwound.is_err());
assert_eq!(zone.fingerprint(), before);
assert_eq!(zone.validate(), Ok(()));
assert!(zone.temp_circles.is_empty());
}
#[test]
fn a_temporary_circle_is_restored_on_an_early_return() {
let mut zone = carve_all(BOARD, &[p(6.0, 6.0)]);
let before = zone.fingerprint();
let answer = zone.with_temp_circle(p(9.0, 9.0), |zone| {
if zone.contains(p(9.0, 9.0)) {
return "still playable";
}
"covered"
});
assert_eq!(answer, "covered");
assert_eq!(zone.fingerprint(), before);
}
#[test]
fn temporary_circles_nest() {
let mut zone = carve_all(BOARD, &[p(5.0, 5.0)]);
let before = zone.fingerprint();
let inner_saw = zone.with_temp_circle(p(11.0, 11.0), |zone| {
assert_eq!(zone.temp_circles.len(), 1);
zone.with_temp_circle(p(14.0, 11.0), |zone| {
assert_eq!(zone.temp_circles.len(), 2);
assert_eq!(zone.validate(), Ok(()));
(zone.contains(p(11.0, 11.0)), zone.contains(p(14.0, 11.0)))
})
});
assert_eq!(inner_saw, (false, false));
assert!(zone.temp_circles.is_empty());
assert_eq!(zone.fingerprint(), before);
}
#[test]
fn a_temporary_circle_is_not_a_stone_and_cannot_be_reclaimed_by_one() {
let mut zone = carve_all(BOARD, &[p(5.0, 5.0)]);
zone.with_temp_circle(p(11.0, 11.0), |zone| {
assert!(!zone.has_circle(s(1)));
assert_eq!(
zone.reclaim_circle(s(1)),
Err(DeadZoneError::NotCarved { stone: s(1) })
);
});
}
#[test]
fn a_stone_can_still_be_carved_while_a_temporary_circle_is_out() {
let mut zone = carve_all(BOARD, &[p(5.0, 5.0)]);
zone.with_temp_circle(p(11.0, 11.0), |zone| {
zone.remove_circle(s(1), p(12.5, 11.0)).unwrap();
assert_eq!(zone.validate(), Ok(()));
});
assert!(zone.has_circle(s(1)));
assert_eq!(zone.validate(), Ok(()));
}
const MEETING: Point = Point::new(9.0, 9.0);
fn concurrent_triple() -> Vec<Point> {
let third = core::f64::consts::TAU / 3.0;
(0..3)
.map(|step| {
let angle = f64::from(step) * third;
p(
MEETING.x + STONE_DIAMETER * angle.cos(),
MEETING.y + STONE_DIAMETER * angle.sin(),
)
})
.collect()
}
#[test]
fn three_dead_zones_through_one_point_meet_there_exactly() {
let zone = carve_all(BOARD, &concurrent_triple());
let meeting = zone.graph.segments_at(MEETING);
assert_eq!(meeting.len(), 3);
let owners: BTreeSet<ShapeId> = meeting
.iter()
.filter_map(|id| zone.graph.segment(*id).map(Segment::parent))
.collect();
assert_eq!(owners.len(), 3);
let counts: Vec<usize> = (0..3)
.filter_map(|stone| zone.dead_zones.get(&s(stone)))
.filter_map(|shape| zone.graph.shape(*shape))
.map(Shape::count)
.collect();
assert_eq!(counts, vec![4, 4, 3]);
assert_eq!(zone.validate(), Ok(()));
}
#[test]
fn the_third_pair_crosses_a_hair_away_and_is_therefore_a_second_point() {
let zone = carve_all(BOARD, &concurrent_triple());
let first = *zone.dead_zones.get(&s(0)).unwrap();
let near_misses: Vec<Point> = zone
.graph
.node_ids(first)
.into_iter()
.filter_map(|id| zone.graph.segment(id).map(Segment::point))
.filter(|point| *point != MEETING && point.distance(MEETING) < 1e-12)
.collect();
let [near_miss] = near_misses.as_slice() else {
panic!("expected exactly one near miss, got {near_misses:?}");
};
assert_eq!(zone.graph.segments_at(*near_miss).len(), 2);
for id in zone.graph.node_ids(first) {
let Some(point) = zone.graph.segment(id).map(Segment::point) else {
continue;
};
if point.distance(MEETING) < 1e-12 {
assert!(!zone.graph.is_active(id));
}
}
}
#[test]
fn a_crossing_three_shapes_share_survives_the_collector() {
let mut zone = carve_all(BOARD, &concurrent_triple());
zone.reclaim_circle(s(2)).unwrap();
assert_eq!(zone.graph.segments_at(MEETING).len(), 2);
assert_eq!(zone.validate(), Ok(()));
}
#[test]
fn the_meeting_point_drains_however_the_dead_zones_are_reclaimed() {
for order in [
[0_u32, 1, 2],
[0, 2, 1],
[1, 0, 2],
[1, 2, 0],
[2, 0, 1],
[2, 1, 0],
] {
let mut zone = carve_all(BOARD, &concurrent_triple());
for stone in order {
zone.reclaim_circle(s(stone)).unwrap();
assert_eq!(zone.validate(), Ok(()));
}
assert!(zone.graph.segments_at(MEETING).is_empty(), "{order:?}");
assert_eq!(zone.graph.segment_count(), 16, "{order:?}");
}
}
#[test]
fn the_concurrent_triple_round_trips_exactly() {
let mut zone = carve_all(BOARD, &concurrent_triple());
let carved = zone.fingerprint();
let last = *concurrent_triple().last().unwrap();
zone.reclaim_circle(s(2)).unwrap();
zone.remove_circle(s(2), last).unwrap();
assert_eq!(zone.fingerprint(), carved);
}
#[test]
fn exactly_tangent_dead_zones_do_not_split_each_other() {
let zone = carve_all(BOARD, &[p(6.0, 9.0), p(6.0 + 2.0 * STONE_DIAMETER, 9.0)]);
for stone in 0..2 {
let shape = *zone.dead_zones.get(&s(stone)).unwrap();
assert_eq!(zone.graph.shape(shape).unwrap().count(), 0);
}
}
#[test]
fn forced_eyes_are_added_and_found_exactly() {
let mut zone = AliveZone::new(BOARD);
zone.add_forced_eye(p(10.0, 10.0));
zone.add_forced_eye(p(12.0, 12.0));
assert_eq!(zone.forced_eye_count(), 2);
assert!(zone.has_forced_eye(p(10.0, 10.0)));
assert!(zone.has_forced_eye(p(12.0, 12.0)));
assert!(!zone.has_forced_eye(p(10.0, 10.000_000_1)));
}
#[test]
fn a_forced_eye_is_removed_by_its_own_point() {
let mut zone = AliveZone::new(BOARD);
zone.add_forced_eye(p(10.0, 10.0));
assert!(zone.remove_forced_eye(p(10.0, 10.0)));
assert_eq!(zone.forced_eye_count(), 0);
assert!(!zone.remove_forced_eye(p(15.0, 15.0)));
assert_eq!(zone.forced_eye_count(), 0);
}
#[test]
fn a_new_stone_consumes_the_forced_eyes_around_it() {
let mut zone = AliveZone::new(BOARD);
for eye in [p(10.0, 10.0), p(10.0, 12.0), p(12.0, 12.0)] {
zone.add_forced_eye(eye);
}
let removed = zone.remove_forced_eyes_near_point(p(10.0, 11.0));
assert_eq!(removed, vec![p(10.0, 10.0), p(10.0, 12.0)]);
assert_eq!(zone.forced_eye_count(), 1);
assert!(zone.has_forced_eye(p(12.0, 12.0)));
}
#[test]
fn an_exact_hit_leaves_the_eyes_around_it_alone() {
let mut zone = AliveZone::new(BOARD);
for eye in [p(10.0, 10.0), p(10.0, 12.0), p(12.0, 12.0)] {
zone.add_forced_eye(eye);
}
let removed = zone.remove_forced_eyes_near_point(p(10.0, 10.0));
assert_eq!(removed, vec![p(10.0, 10.0)]);
assert_eq!(zone.forced_eye_count(), 2);
assert!(zone.has_forced_eye(p(10.0, 12.0)));
}
#[test]
fn an_eye_exactly_a_stone_diameter_away_is_consumed() {
let mut zone = AliveZone::new(BOARD);
zone.add_forced_eye(p(10.0, 10.0 + STONE_DIAMETER));
let removed = zone.remove_forced_eyes_near_point(p(10.0, 10.0));
assert_eq!(removed.len(), 1);
}
#[test]
fn removing_near_a_point_with_nothing_around_it_removes_nothing() {
let mut zone = AliveZone::new(BOARD);
zone.add_forced_eye(p(2.0, 2.0));
assert!(zone.remove_forced_eyes_near_point(p(16.0, 16.0)).is_empty());
assert_eq!(zone.forced_eye_count(), 1);
}
#[test]
fn forced_eyes_are_walked_in_a_reproducible_order() {
let build = |order: [Point; 3]| {
let mut zone = AliveZone::new(BOARD);
for eye in order {
zone.add_forced_eye(eye);
}
zone.forced_eyes().collect::<Vec<Point>>()
};
assert_eq!(
build([p(9.0, 1.0), p(1.0, 9.0), p(5.0, 5.0)]),
build([p(5.0, 5.0), p(9.0, 1.0), p(1.0, 9.0)])
);
}
#[test]
fn every_point_on_an_empty_board_is_playable() {
let zone = AliveZone::new(100.0);
assert!(zone.contains(p(50.0, 50.0)));
assert!(zone.contains(p(10.0, 10.0)));
assert!(zone.contains(p(90.0, 90.0)));
}
#[test]
fn a_point_off_the_inset_board_is_not_playable() {
let zone = AliveZone::new(100.0);
assert!(!zone.contains(p(0.1, 50.0)));
assert!(!zone.contains(p(99.9, 50.0)));
assert!(!zone.contains(p(50.0, 0.1)));
assert!(!zone.contains(p(50.0, 99.9)));
assert!(!zone.contains(p(-10.0, 50.0)));
assert!(!zone.contains(p(110.0, 50.0)));
}
#[test]
fn a_point_inside_a_dead_zone_is_not_playable() {
let zone = carve_all(100.0, &[p(50.0, 50.0)]);
assert!(!zone.contains(p(50.5, 50.5)));
assert!(!zone.contains(p(51.0, 50.0)));
assert!(zone.contains(p(60.0, 50.0)));
assert!(zone.contains(p(10.0, 10.0)));
}
#[test]
fn a_point_on_the_rim_of_a_dead_zone_is_playable() {
let zone = carve_all(100.0, &[p(50.0, 50.0)]);
assert!(zone.contains(p(50.0 + STONE_DIAMETER, 50.0)));
}
#[test]
fn several_dead_zones_are_all_consulted() {
let zone = carve_all(100.0, &[p(30.0, 50.0), p(70.0, 50.0)]);
assert!(!zone.contains(p(30.0, 50.0)));
assert!(!zone.contains(p(70.0, 50.0)));
assert!(zone.contains(p(50.0, 50.0)));
}
#[test]
fn a_point_where_two_dead_zones_overlap_is_not_playable() {
let zone = carve_all(100.0, &[p(50.0, 50.0), p(51.5, 50.0)]);
assert!(!zone.contains(p(50.5, 50.0)));
}
#[test]
fn a_forced_eye_is_playable_however_it_is_covered() {
let mut zone = carve_all(100.0, &[p(50.0, 50.0)]);
assert!(!zone.contains(p(50.0, 50.0)));
zone.add_forced_eye(p(50.0, 50.0));
assert!(zone.contains(p(50.0, 50.0)));
assert!(!zone.contains(p(50.5, 50.0)));
zone.remove_forced_eye(p(50.0, 50.0));
assert!(!zone.contains(p(50.0, 50.0)));
}
#[test]
fn a_forced_eye_overrides_every_shape_including_a_board_edge() {
let mut zone = AliveZone::new(100.0);
zone.add_forced_eye(p(0.5, 50.0));
assert!(
!zone.contains(p(0.5, 51.0)),
"the inset margin still covers"
);
assert!(zone.contains(p(0.5, 50.0)));
}
#[test]
fn a_forced_eye_cannot_put_a_position_back_on_the_board() {
let mut zone = AliveZone::new(100.0);
zone.add_forced_eye(p(-5.0, 50.0));
zone.add_forced_eye(p(f64::NAN, 50.0));
assert!(!zone.contains(p(-5.0, 50.0)));
assert!(!zone.contains(p(f64::NAN, 50.0)));
assert!(!zone.is_placeable(p(-5.0, 50.0)));
}
#[test]
fn segments_are_never_consulted_by_contains() {
let mut zone = carve_all(BOARD, &[p(6.0, 6.0), p(8.5, 7.0), p(7.0, 9.0)]);
let before = probe_grid(&zone, 9);
zone.reclaim_circle(s(1)).unwrap();
zone.remove_circle(s(1), p(8.5, 7.0)).unwrap();
assert_eq!(probe_grid(&zone, 9), before);
}
#[test]
fn a_reclaimed_dead_zone_leaves_no_shape_behind() {
let mut zone = carve_all(BOARD, &[p(5.0, 5.0), p(6.5, 5.0)]);
assert_eq!(zone.graph.shape_ids().count(), 6);
zone.reclaim_circle(s(0)).unwrap();
assert_eq!(zone.graph.shape_ids().count(), 5);
zone.reclaim_circle(s(1)).unwrap();
assert_eq!(zone.graph.shape_ids().count(), 4);
assert_eq!(zone.graph.segment_count(), 16);
}
#[test]
fn a_recarved_dead_zone_gets_a_fresh_shape_id() {
let mut zone = carve_all(BOARD, &[p(5.0, 5.0)]);
let first = *zone.dead_zones.get(&s(0)).unwrap();
zone.reclaim_circle(s(0)).unwrap();
zone.remove_circle(s(0), p(5.0, 5.0)).unwrap();
let second = *zone.dead_zones.get(&s(0)).unwrap();
assert_ne!(first, second);
}
#[test]
fn every_crossing_of_a_carve_is_shared_by_exactly_two_shapes() {
let zone = carve_all(BOARD, &[p(2.0, 10.0), p(3.5, 11.0), p(10.0, 10.0)]);
for id in zone.graph.shape_ids().collect::<Vec<ShapeId>>() {
for node in zone.graph.node_ids(id) {
let Some(segment) = zone.graph.segment(node) else {
continue;
};
let shared = zone.graph.segments_at(Segment::point(segment)).len();
assert!(shared <= 2, "{shared} segments share one point");
}
}
}
}