1use std::fmt;
6
7pub const BOARD_SIZE: usize = 15;
8pub const NUM_CELLS: usize = BOARD_SIZE * BOARD_SIZE; pub const LINE_PATTERN_FRONTIER_MAX: usize = 41;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum Stone {
13 Black,
14 White,
15}
16
17impl Stone {
18 pub fn opponent(self) -> Stone {
19 match self {
20 Stone::Black => Stone::White,
21 Stone::White => Stone::Black,
22 }
23 }
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum RuleSet {
28 Freestyle,
29 Standard,
30 Caro,
31 Renju,
34}
35
36impl RuleSet {
37 #[inline]
38 pub const fn uses_exact_five(self) -> bool {
39 matches!(self, RuleSet::Standard | RuleSet::Renju)
40 }
41
42 #[inline]
43 pub const fn line_wins(self, side: Stone, count: u32, open_ends: u32) -> bool {
44 match self {
45 RuleSet::Freestyle => count >= 5,
46 RuleSet::Standard => count == 5,
47 RuleSet::Caro => count >= 6 || (count == 5 && open_ends > 0),
48 RuleSet::Renju => match side {
49 Stone::Black => count == 5,
50 Stone::White => count >= 5,
51 },
52 }
53 }
54}
55
56#[derive(Clone, Copy, PartialEq, Eq)]
59pub struct BitBoard {
60 pub lo: u128,
61 pub hi: u128,
62}
63
64impl BitBoard {
65 pub const EMPTY: Self = Self { lo: 0, hi: 0 };
66
67 #[inline]
68 pub fn get(&self, idx: usize) -> bool {
69 if idx < 128 {
70 (self.lo >> idx) & 1 != 0
71 } else {
72 (self.hi >> (idx - 128)) & 1 != 0
73 }
74 }
75
76 #[inline]
77 pub fn set(&mut self, idx: usize) {
78 if idx < 128 {
79 self.lo |= 1u128 << idx;
80 } else {
81 self.hi |= 1u128 << (idx - 128);
82 }
83 }
84
85 #[inline]
86 pub fn clear(&mut self, idx: usize) {
87 if idx < 128 {
88 self.lo &= !(1u128 << idx);
89 } else {
90 self.hi &= !(1u128 << (idx - 128));
91 }
92 }
93
94 #[inline]
95 pub fn or(&self, other: &BitBoard) -> BitBoard {
96 BitBoard {
97 lo: self.lo | other.lo,
98 hi: self.hi | other.hi,
99 }
100 }
101
102 #[inline]
103 pub fn count_ones(&self) -> u32 {
104 self.lo.count_ones() + self.hi.count_ones()
105 }
106
107 #[inline]
112 pub fn iter_ones(&self) -> BitBoardIter {
113 BitBoardIter {
114 lo: self.lo,
115 hi: self.hi,
116 }
117 }
118}
119
120pub struct BitBoardIter {
121 lo: u128,
122 hi: u128,
123}
124
125impl Iterator for BitBoardIter {
126 type Item = usize;
127 #[inline]
128 fn next(&mut self) -> Option<usize> {
129 if self.lo != 0 {
130 let idx = self.lo.trailing_zeros() as usize;
131 self.lo &= self.lo - 1;
132 Some(idx)
133 } else if self.hi != 0 {
134 let idx = 128 + self.hi.trailing_zeros() as usize;
135 self.hi &= self.hi - 1;
136 Some(idx)
137 } else {
138 None
139 }
140 }
141}
142
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub enum GameResult {
145 BlackWin,
146 WhiteWin,
147 Draw,
148 Ongoing,
149}
150
151pub type Move = usize;
153
154#[inline]
155pub fn to_rc(idx: usize) -> (usize, usize) {
156 (idx / BOARD_SIZE, idx % BOARD_SIZE)
157}
158
159#[inline]
160pub fn to_idx(row: usize, col: usize) -> usize {
161 row * BOARD_SIZE + col
162}
163
164#[inline]
165fn in_board(row: i32, col: i32) -> bool {
166 row >= 0 && row < BOARD_SIZE as i32 && col >= 0 && col < BOARD_SIZE as i32
167}
168
169mod zobrist {
173 use super::{NUM_CELLS, Stone};
174
175 pub(super) const fn splitmix64(seed: u64) -> u64 {
177 let mut x = seed;
178 x = x.wrapping_add(0x9E3779B97F4A7C15);
179 x = (x ^ (x >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
180 x = (x ^ (x >> 27)).wrapping_mul(0x94D049BB133111EB);
181 x ^ (x >> 31)
182 }
183
184 const fn build_keys() -> [[u64; NUM_CELLS]; 2] {
185 let mut out = [[0u64; NUM_CELLS]; 2];
186 let mut color = 0;
187 while color < 2 {
188 let mut cell = 0;
189 while cell < NUM_CELLS {
190 let seed = (color as u64) * 0x9E3779B97F4A7C15 ^ (cell as u64);
191 out[color][cell] = splitmix64(seed);
192 cell += 1;
193 }
194 color += 1;
195 }
196 out
197 }
198
199 pub const STONE_KEYS: [[u64; NUM_CELLS]; 2] = build_keys();
200 pub const SIDE_TO_MOVE_KEY: u64 = splitmix64(0xCAFE_BABE_DEAD_BEEF);
201
202 #[inline]
203 pub const fn key_for(stone: Stone, cell: usize) -> u64 {
204 let color = match stone {
205 Stone::Black => 0,
206 Stone::White => 1,
207 };
208 STONE_KEYS[color][cell]
209 }
210}
211
212pub use zobrist::SIDE_TO_MOVE_KEY as ZOBRIST_SIDE;
213
214#[inline]
215pub const fn zobrist_stone_key(stone: Stone, cell: usize) -> u64 {
216 zobrist::key_for(stone, cell)
217}
218
219#[inline]
225pub const fn d4_rule_key(rule: RuleSet) -> u64 {
226 let seed = match rule {
227 RuleSet::Freestyle => 0xD4C0_0000_0000_0000,
228 RuleSet::Standard => 0xD4C0_0000_0000_0001,
229 RuleSet::Caro => 0xD4C0_0000_0000_0002,
230 RuleSet::Renju => 0xD4C0_0000_0000_0003,
231 };
232 zobrist::splitmix64(seed)
233}
234
235pub type LinePatternState = Box<[[u16; 4]; NUM_CELLS]>;
243
244const NO_CANDIDATE_SOURCE: u8 = u8::MAX;
245
246#[derive(Clone)]
252struct CandidateFrontierState {
253 radius2_count: [u8; NUM_CELLS],
254 candidates: BitBoard,
255 min_source: [u8; NUM_CELLS],
256 by_min_source: [BitBoard; NUM_CELLS],
257 nonempty_sources: BitBoard,
258}
259
260impl CandidateFrontierState {
261 fn empty() -> Self {
262 Self {
263 radius2_count: [0; NUM_CELLS],
264 candidates: BitBoard::EMPTY,
265 min_source: [NO_CANDIDATE_SOURCE; NUM_CELLS],
266 by_min_source: [BitBoard::EMPTY; NUM_CELLS],
267 nonempty_sources: BitBoard::EMPTY,
268 }
269 }
270
271 #[inline]
272 fn bucket_insert(&mut self, source: usize, cell: usize) {
273 let bucket = &mut self.by_min_source[source];
274 let was_empty = bucket.lo == 0 && bucket.hi == 0;
275 bucket.set(cell);
276 if was_empty {
277 self.nonempty_sources.set(source);
278 }
279 }
280
281 #[inline]
282 fn bucket_remove(&mut self, source: usize, cell: usize) {
283 let bucket = &mut self.by_min_source[source];
284 debug_assert!(bucket.get(cell));
285 bucket.clear(cell);
286 if bucket.lo == 0 && bucket.hi == 0 {
287 self.nonempty_sources.clear(source);
288 }
289 }
290
291 #[inline]
292 fn candidate_insert(&mut self, source: usize, cell: usize) {
293 debug_assert!(!self.candidates.get(cell));
294 debug_assert_eq!(self.min_source[cell], NO_CANDIDATE_SOURCE);
295 self.candidates.set(cell);
296 self.min_source[cell] = source as u8;
297 self.bucket_insert(source, cell);
298 }
299
300 #[inline]
301 fn candidate_remove(&mut self, cell: usize) {
302 debug_assert!(self.candidates.get(cell));
303 let source = self.min_source[cell] as usize;
304 debug_assert!(source < NUM_CELLS);
305 self.bucket_remove(source, cell);
306 self.candidates.clear(cell);
307 self.min_source[cell] = NO_CANDIDATE_SOURCE;
308 }
309
310 #[inline]
311 fn candidate_rekey(&mut self, new_source: usize, cell: usize) {
312 debug_assert!(self.candidates.get(cell));
313 let old_source = self.min_source[cell] as usize;
314 debug_assert!(old_source < NUM_CELLS);
315 self.bucket_remove(old_source, cell);
316 self.min_source[cell] = new_source as u8;
317 self.bucket_insert(new_source, cell);
318 }
319}
320
321pub type LinePackedWindowState = Box<[[u32; 4]; NUM_CELLS]>;
326
327#[doc(hidden)]
334#[derive(Clone, Default)]
335pub struct BoardSearchState {
336 line_packed_windows: Option<LinePackedWindowState>,
337 candidate_frontier: Option<Box<CandidateFrontierState>>,
338 d4_hash: Option<crate::d4_hash::D4HashState>,
339 synchronized_position: Option<(u64, usize, RuleSet)>,
340}
341
342impl BoardSearchState {
343 pub fn new() -> Self {
344 Self::default()
345 }
346
347 #[inline]
349 pub fn is_synchronized(&self, board: &Board) -> bool {
350 if self.line_packed_windows.is_none()
351 && self.candidate_frontier.is_none()
352 && self.d4_hash.is_none()
353 {
354 true
355 } else {
356 self.synchronized_position
357 == Some((board.zobrist, board.move_count, board.effective_rule_set()))
358 }
359 }
360
361 pub fn synchronize(&mut self, board: &Board) {
364 if self.is_synchronized(board) {
365 return;
366 }
367 let packed_enabled = self.line_packed_windows.is_some();
368 let frontier_enabled = self.candidate_frontier.is_some();
369 let d4_hash_enabled = self.d4_hash.is_some();
370 self.line_packed_windows = None;
371 self.candidate_frontier = None;
372 self.d4_hash = None;
373 self.synchronized_position = None;
374 if packed_enabled {
375 self.set_packed_line_windows_enabled(board, true);
376 }
377 if frontier_enabled {
378 self.set_candidate_frontier_enabled(board, true);
379 }
380 if d4_hash_enabled {
381 self.set_d4_hash_enabled(board, true);
382 }
383 }
384
385 #[inline]
386 fn record_position(&mut self, board: &Board) {
387 self.synchronized_position = (self.line_packed_windows.is_some()
388 || self.candidate_frontier.is_some()
389 || self.d4_hash.is_some())
390 .then_some((board.zobrist, board.move_count, board.effective_rule_set()));
391 }
392
393 pub fn set_packed_line_windows_enabled(&mut self, board: &Board, enabled: bool) {
399 self.synchronize(board);
400 if enabled {
401 if self.line_packed_windows.is_none() {
402 let mut windows = Box::new([[0u32; 4]; NUM_CELLS]);
403 const DIRS: [(i32, i32); 4] = [(0, 1), (1, 0), (1, 1), (1, -1)];
404 for cell in 0..NUM_CELLS {
405 let row = (cell / BOARD_SIZE) as i32;
406 let col = (cell % BOARD_SIZE) as i32;
407 for (dir_idx, &(dr, dc)) in DIRS.iter().enumerate() {
408 let window = crate::pattern_table::read_window(
409 &board.black,
410 &board.white,
411 row,
412 col,
413 dr,
414 dc,
415 );
416 let packed = crate::pattern_table::pack_window(&window);
417 debug_assert_eq!(
418 board.line_pattern_ids[cell][dir_idx],
419 crate::pattern_table::lookup_mapped_id(packed),
420 "line pattern ID stale before packed-window enable"
421 );
422 windows[cell][dir_idx] = packed;
423 }
424 }
425 self.line_packed_windows = Some(windows);
426 }
427 } else {
428 self.line_packed_windows = None;
429 }
430 self.record_position(board);
431 }
432
433 #[inline]
434 pub fn packed_line_windows_enabled(&self) -> bool {
435 self.line_packed_windows.is_some()
436 }
437
438 #[inline]
440 pub fn packed_line_window(&self, cell: usize, dir_idx: usize) -> Option<u32> {
441 self.line_packed_windows
442 .as_ref()
443 .map(|windows| windows[cell][dir_idx])
444 }
445
446 pub fn set_candidate_frontier_enabled(&mut self, board: &Board, enabled: bool) {
450 self.synchronize(board);
451 if enabled {
452 if self.candidate_frontier.is_none() {
453 self.candidate_frontier = Some(board.rebuild_candidate_frontier());
454 }
455 } else {
456 self.candidate_frontier = None;
457 }
458 self.record_position(board);
459 }
460
461 #[inline]
462 pub fn candidate_frontier_enabled(&self) -> bool {
463 self.candidate_frontier.is_some()
464 }
465
466 #[doc(hidden)]
471 pub fn set_d4_hash_enabled(&mut self, board: &Board, enabled: bool) {
472 self.synchronize(board);
473 if enabled {
474 if self.d4_hash.is_none() {
475 self.d4_hash = Some(crate::d4_hash::D4HashState::rebuild(board));
476 }
477 } else {
478 self.d4_hash = None;
479 }
480 self.record_position(board);
481 }
482
483 #[doc(hidden)]
484 #[inline]
485 pub fn d4_hash_enabled(&self) -> bool {
486 self.d4_hash.is_some()
487 }
488
489 #[doc(hidden)]
492 #[inline]
493 pub fn d4_hashes(&self, board: &Board) -> Option<[u64; 8]> {
494 if !self.is_synchronized(board) {
495 return None;
496 }
497 self.d4_hash.as_ref().map(|state| *state.hashes())
498 }
499
500 #[doc(hidden)]
502 #[inline]
503 pub fn d4_canonical_context(&self, board: &Board) -> Option<crate::d4_hash::CanonicalContext> {
504 if !self.is_synchronized(board) {
505 return None;
506 }
507 self.d4_hash
508 .as_ref()
509 .map(crate::d4_hash::D4HashState::canonical_context)
510 }
511
512 #[doc(hidden)]
514 #[inline]
515 pub fn d4_predicted_child_hashes(&self, board: &Board, mv: Move) -> Option<[u64; 8]> {
516 if !self.is_synchronized(board) {
517 return None;
518 }
519 self.d4_hash.as_ref()?.predicted_child_hashes(board, mv)
520 }
521
522 #[doc(hidden)]
524 #[inline]
525 pub fn d4_predicted_child_context(
526 &self,
527 board: &Board,
528 mv: Move,
529 ) -> Option<crate::d4_hash::CanonicalContext> {
530 if !self.is_synchronized(board) {
531 return None;
532 }
533 self.d4_hash.as_ref()?.predicted_child_context(board, mv)
534 }
535
536 pub fn candidate_moves(&self, board: &Board) -> Vec<Move> {
538 if !self.is_synchronized(board) {
539 return board.candidate_moves();
540 }
541 self.candidate_moves_synchronized(board)
542 }
543
544 #[inline]
545 pub(crate) fn candidate_moves_synchronized(&self, board: &Board) -> Vec<Move> {
546 debug_assert!(
547 self.is_synchronized(board),
548 "BoardSearchState is stale for candidate generation"
549 );
550 if board.move_count == 0 {
551 return vec![to_idx(7, 7)];
552 }
553 let Some(frontier) = self.candidate_frontier.as_ref() else {
554 return board.candidate_moves();
555 };
556 board.candidate_moves_from_frontier(frontier)
557 }
558
559 #[inline]
561 pub fn make_move(&mut self, board: &mut Board, mv: Move) {
562 self.synchronize(board);
563 self.make_move_synchronized(board, mv);
564 }
565
566 #[inline]
567 pub(crate) fn make_move_synchronized(&mut self, board: &mut Board, mv: Move) {
568 debug_assert!(
569 self.is_synchronized(board),
570 "BoardSearchState is stale before make_move"
571 );
572 let placed = board.side_to_move;
573 board.make_move_with_search_state(
574 mv,
575 self.line_packed_windows.as_deref_mut(),
576 self.candidate_frontier.as_deref_mut(),
577 );
578 if let Some(d4_hash) = self.d4_hash.as_mut() {
579 d4_hash.apply_move(placed, mv);
580 }
581 self.record_position(board);
582 }
583
584 #[inline]
586 pub fn undo_move(&mut self, board: &mut Board) {
587 self.synchronize(board);
588 self.undo_move_synchronized(board);
589 }
590
591 #[inline]
592 pub(crate) fn undo_move_synchronized(&mut self, board: &mut Board) {
593 debug_assert!(
594 self.is_synchronized(board),
595 "BoardSearchState is stale before undo_move"
596 );
597 let removed = board
598 .history
599 .last()
600 .copied()
601 .map(|mv| (board.side_to_move.opponent(), mv));
602 board.undo_move_with_search_state(
603 self.line_packed_windows.as_deref_mut(),
604 self.candidate_frontier.as_deref_mut(),
605 );
606 if let (Some(d4_hash), Some((placed, mv))) = (self.d4_hash.as_mut(), removed) {
607 d4_hash.apply_move(placed, mv);
608 }
609 self.record_position(board);
610 }
611}
612
613#[derive(Clone)]
614pub struct Board {
615 pub black: BitBoard,
616 pub white: BitBoard,
617 pub side_to_move: Stone,
618 pub move_count: usize,
619 pub last_move: Option<Move>,
620 pub history: Vec<Move>,
622 pub zobrist: u64,
626 pub line_pattern_ids: LinePatternState,
631 pub rule_set: RuleSet,
633 pub exact5: bool,
636}
637
638impl Board {
639 pub fn new() -> Self {
640 let mut b = Self {
641 black: BitBoard::EMPTY,
642 white: BitBoard::EMPTY,
643 side_to_move: Stone::Black,
644 move_count: 0,
645 last_move: None,
646 history: Vec::with_capacity(NUM_CELLS),
647 zobrist: 0,
649 rule_set: RuleSet::Freestyle,
652 line_pattern_ids: Box::new([[0u16; 4]; NUM_CELLS]),
653 exact5: false,
654 };
655 b.fill_initial_pattern_ids();
656 b
657 }
658
659 #[inline]
660 pub fn set_rule_set(&mut self, rule_set: RuleSet) {
661 self.rule_set = rule_set;
662 self.exact5 = rule_set.uses_exact_five();
663 }
664
665 #[inline]
666 pub fn effective_rule_set(&self) -> RuleSet {
667 if self.exact5 && matches!(self.rule_set, RuleSet::Freestyle) {
670 RuleSet::Standard
671 } else {
672 self.rule_set
673 }
674 }
675
676 fn fill_initial_pattern_ids(&mut self) {
680 const DIRS: [(i32, i32); 4] = [(0, 1), (1, 0), (1, 1), (1, -1)];
681 for cell in 0..NUM_CELLS {
682 let row = (cell / BOARD_SIZE) as i32;
683 let col = (cell % BOARD_SIZE) as i32;
684 for (dir_idx, &(dr, dc)) in DIRS.iter().enumerate() {
685 let w =
686 crate::pattern_table::read_window(&self.black, &self.white, row, col, dr, dc);
687 let packed = crate::pattern_table::pack_window(&w);
688 self.line_pattern_ids[cell][dir_idx] =
689 crate::pattern_table::lookup_mapped_id(packed);
690 }
691 }
692 }
693
694 #[inline]
696 pub fn is_empty(&self, idx: usize) -> bool {
697 let occupied = self.black.or(&self.white);
698 !occupied.get(idx)
699 }
700
701 #[inline]
703 pub fn current_stones(&self) -> &BitBoard {
704 match self.side_to_move {
705 Stone::Black => &self.black,
706 Stone::White => &self.white,
707 }
708 }
709
710 #[inline]
712 pub fn opponent_stones(&self) -> &BitBoard {
713 match self.side_to_move {
714 Stone::Black => &self.white,
715 Stone::White => &self.black,
716 }
717 }
718
719 pub fn legal_moves(&self) -> Vec<Move> {
721 let occupied = self.black.or(&self.white);
722 let mut moves = Vec::with_capacity(NUM_CELLS - self.move_count);
723 for idx in 0..NUM_CELLS {
724 if !occupied.get(idx) {
725 moves.push(idx);
726 }
727 }
728 moves
729 }
730
731 #[inline]
732 fn for_radius2_neighbors(cell: usize, mut f: impl FnMut(usize)) {
733 let (row, col) = to_rc(cell);
734 let row_start = row.saturating_sub(2);
735 let row_end = (row + 2).min(BOARD_SIZE - 1);
736 let col_start = col.saturating_sub(2);
737 let col_end = (col + 2).min(BOARD_SIZE - 1);
738 for neighbor_row in row_start..=row_end {
739 for neighbor_col in col_start..=col_end {
740 let neighbor = to_idx(neighbor_row, neighbor_col);
741 if neighbor != cell {
742 f(neighbor);
743 }
744 }
745 }
746 }
747
748 #[inline]
749 fn first_radius2_source(occupied: &BitBoard, cell: usize) -> Option<usize> {
750 let mut first = None;
751 Self::for_radius2_neighbors(cell, |neighbor| {
752 if first.is_none() && occupied.get(neighbor) {
753 first = Some(neighbor);
754 }
755 });
756 first
757 }
758
759 fn rebuild_candidate_frontier(&self) -> Box<CandidateFrontierState> {
760 let occupied = self.black.or(&self.white);
761 let mut state = Box::new(CandidateFrontierState::empty());
762 for source in occupied.iter_ones() {
763 Self::for_radius2_neighbors(source, |cell| {
764 state.radius2_count[cell] += 1;
765 });
766 }
767 for cell in 0..NUM_CELLS {
768 if !occupied.get(cell) && state.radius2_count[cell] > 0 {
769 let source = Self::first_radius2_source(&occupied, cell)
770 .expect("positive radius-2 count must have a source");
771 state.candidate_insert(source, cell);
772 }
773 }
774 state
775 }
776
777 fn candidate_moves_legacy(&self) -> Vec<Move> {
778 let occupied = self.black.or(&self.white);
779 let mut seen = [false; NUM_CELLS];
780 let mut moves = Vec::with_capacity(64);
781
782 for idx in 0..NUM_CELLS {
783 if !occupied.get(idx) {
784 continue;
785 }
786 let (r, c) = to_rc(idx);
787 for dr in -2i32..=2 {
788 for dc in -2i32..=2 {
789 if dr == 0 && dc == 0 {
790 continue;
791 }
792 let nr = r as i32 + dr;
793 let nc = c as i32 + dc;
794 if nr < 0 || nr >= BOARD_SIZE as i32 || nc < 0 || nc >= BOARD_SIZE as i32 {
795 continue;
796 }
797 let nidx = to_idx(nr as usize, nc as usize);
798 if !seen[nidx] && !occupied.get(nidx) {
799 seen[nidx] = true;
800 moves.push(nidx);
801 }
802 }
803 }
804 }
805
806 moves
807 }
808
809 pub fn candidate_moves(&self) -> Vec<Move> {
811 if self.move_count == 0 {
812 return vec![to_idx(7, 7)];
814 }
815 return self.candidate_moves_legacy();
816 }
817
818 fn candidate_moves_from_frontier(&self, frontier: &CandidateFrontierState) -> Vec<Move> {
819 let mut moves = Vec::with_capacity(frontier.candidates.count_ones() as usize);
820 for source in frontier.nonempty_sources.iter_ones() {
821 moves.extend(frontier.by_min_source[source].iter_ones());
822 }
823 debug_assert_eq!(moves.len(), frontier.candidates.count_ones() as usize);
824 moves
825 }
826
827 #[inline]
828 fn update_candidate_frontier_after_make(
829 &self,
830 frontier: &mut CandidateFrontierState,
831 mv: Move,
832 ) {
833 let occupied = self.black.or(&self.white);
834
835 if frontier.candidates.get(mv) {
836 frontier.candidate_remove(mv);
837 }
838 Self::for_radius2_neighbors(mv, |cell| {
839 let old_count = frontier.radius2_count[cell];
840 debug_assert!(old_count < 24);
841 frontier.radius2_count[cell] = old_count + 1;
842 if occupied.get(cell) {
843 return;
844 }
845 if old_count == 0 {
846 frontier.candidate_insert(mv, cell);
847 } else {
848 let old_source = frontier.min_source[cell] as usize;
849 debug_assert!(old_source < NUM_CELLS);
850 if mv < old_source {
851 frontier.candidate_rekey(mv, cell);
852 }
853 }
854 });
855 }
856
857 #[inline]
858 fn update_candidate_frontier_after_undo(
859 &self,
860 frontier: &mut CandidateFrontierState,
861 mv: Move,
862 ) {
863 let occupied = self.black.or(&self.white);
864
865 Self::for_radius2_neighbors(mv, |cell| {
866 let old_count = frontier.radius2_count[cell];
867 debug_assert!(old_count > 0);
868 frontier.radius2_count[cell] = old_count - 1;
869 if occupied.get(cell) {
870 return;
871 }
872 if old_count == 1 {
873 frontier.candidate_remove(cell);
874 } else if frontier.min_source[cell] as usize == mv {
875 let new_source = Self::first_radius2_source(&occupied, cell)
876 .expect("remaining radius-2 count must have a source");
877 frontier.candidate_rekey(new_source, cell);
878 }
879 });
880
881 if frontier.radius2_count[mv] > 0 {
882 let source = Self::first_radius2_source(&occupied, mv)
883 .expect("newly empty candidate must have a radius-2 source");
884 frontier.candidate_insert(source, mv);
885 }
886 }
887
888 pub fn make_move(&mut self, mv: Move) {
890 self.make_move_with_search_state(mv, None, None);
891 }
892
893 #[inline]
894 fn make_move_with_search_state(
895 &mut self,
896 mv: Move,
897 line_packed_windows: Option<&mut [[u32; 4]; NUM_CELLS]>,
898 candidate_frontier: Option<&mut CandidateFrontierState>,
899 ) {
900 debug_assert!(mv < NUM_CELLS);
901 debug_assert!(self.is_empty(mv));
902
903 let placed = self.side_to_move;
904 match placed {
905 Stone::Black => self.black.set(mv),
906 Stone::White => self.white.set(mv),
907 }
908 self.zobrist ^= zobrist_stone_key(placed, mv);
910 self.zobrist ^= ZOBRIST_SIDE;
911
912 self.history.push(mv);
913 self.last_move = Some(mv);
914 self.move_count += 1;
915 self.side_to_move = placed.opponent();
916
917 if let Some(frontier) = candidate_frontier {
919 self.update_candidate_frontier_after_make(frontier, mv);
920 }
921
922 self.update_line_patterns_around(
925 mv,
926 match placed {
927 Stone::Black => 1,
928 Stone::White => 2,
929 },
930 line_packed_windows,
931 );
932 }
933
934 pub fn undo_move(&mut self) {
936 self.undo_move_with_search_state(None, None);
937 }
938
939 #[inline]
940 fn undo_move_with_search_state(
941 &mut self,
942 line_packed_windows: Option<&mut [[u32; 4]; NUM_CELLS]>,
943 candidate_frontier: Option<&mut CandidateFrontierState>,
944 ) {
945 if let Some(mv) = self.history.pop() {
946 self.side_to_move = self.side_to_move.opponent();
947 let placed = self.side_to_move;
948 self.move_count -= 1;
949 match placed {
950 Stone::Black => self.black.clear(mv),
951 Stone::White => self.white.clear(mv),
952 }
953 self.zobrist ^= zobrist_stone_key(placed, mv);
955 self.zobrist ^= ZOBRIST_SIDE;
956
957 self.last_move = self.history.last().copied();
958
959 if let Some(frontier) = candidate_frontier {
962 self.update_candidate_frontier_after_undo(frontier, mv);
963 }
964
965 self.update_line_patterns_around(mv, 0, line_packed_windows);
968 }
969 }
970
971 pub fn line_pattern_dirty_cells(
975 mv: Move,
976 out: &mut [usize; LINE_PATTERN_FRONTIER_MAX],
977 ) -> usize {
978 let mut seen = [false; NUM_CELLS];
979 let mut len = 0usize;
980 Self::for_line_pattern_frontier(mv, |cell, _dir_idx, _offset| {
981 if !seen[cell] {
982 seen[cell] = true;
983 debug_assert!(len < LINE_PATTERN_FRONTIER_MAX);
984 out[len] = cell;
985 len += 1;
986 }
987 });
988 len
989 }
990
991 #[inline]
992 fn for_line_pattern_frontier(mut mv: Move, mut f: impl FnMut(usize, usize, i32)) {
993 const DIRS: [(i32, i32); 4] = [(0, 1), (1, 0), (1, 1), (1, -1)];
994 debug_assert!(mv < NUM_CELLS);
995 let row = (mv / BOARD_SIZE) as i32;
996 let col = (mv % BOARD_SIZE) as i32;
997 for (dir_idx, &(dr, dc)) in DIRS.iter().enumerate() {
998 for offset in -5i32..=5 {
999 let r = row + dr * offset;
1000 let c = col + dc * offset;
1001 if r < 0 || r >= BOARD_SIZE as i32 || c < 0 || c >= BOARD_SIZE as i32 {
1002 continue;
1003 }
1004 mv = (r as usize) * BOARD_SIZE + c as usize;
1005 f(mv, dir_idx, offset);
1006 }
1007 }
1008 }
1009
1010 #[inline]
1014 fn update_line_patterns_around(
1015 &mut self,
1016 mv: Move,
1017 new_cell: u32,
1018 line_packed_windows: Option<&mut [[u32; 4]; NUM_CELLS]>,
1019 ) {
1020 const DIRS: [(i32, i32); 4] = [(0, 1), (1, 0), (1, 1), (1, -1)];
1021 debug_assert!(new_cell <= 2);
1022 if let Some(windows) = line_packed_windows {
1023 let ids = &mut self.line_pattern_ids;
1024 Self::for_line_pattern_frontier(mv, |cell, dir_idx, offset| {
1025 let shift = ((5 + offset) * 2) as u32;
1029 let mask = 0b11u32 << shift;
1030 let packed = (windows[cell][dir_idx] & !mask) | (new_cell << shift);
1031 windows[cell][dir_idx] = packed;
1032 ids[cell][dir_idx] = crate::pattern_table::lookup_mapped_id(packed);
1033 });
1034 } else {
1035 Self::for_line_pattern_frontier(mv, |cell, dir_idx, _offset| {
1036 let (dr, dc) = DIRS[dir_idx];
1037 let r = (cell / BOARD_SIZE) as i32;
1038 let c = (cell % BOARD_SIZE) as i32;
1039 let window =
1040 crate::pattern_table::read_window(&self.black, &self.white, r, c, dr, dc);
1041 let packed = crate::pattern_table::pack_window(&window);
1042 self.line_pattern_ids[cell][dir_idx] =
1043 crate::pattern_table::lookup_mapped_id(packed);
1044 });
1045 }
1046 }
1047
1048 pub fn check_win(&self, mv: Move) -> bool {
1050 let (row, col) = to_rc(mv);
1051 let (side, stone) = if self.black.get(mv) {
1052 (Stone::Black, &self.black)
1053 } else if self.white.get(mv) {
1054 (Stone::White, &self.white)
1055 } else {
1056 return false;
1057 };
1058 let rules = self.effective_rule_set();
1059
1060 let directions: [(i32, i32); 4] = [(0, 1), (1, 0), (1, 1), (1, -1)];
1061 for &(dr, dc) in &directions {
1062 let (count, open_ends) = self.line_run(stone, row as i32, col as i32, dr, dc);
1063 if rules.line_wins(side, count, open_ends) {
1064 return true;
1065 }
1066 }
1067 false
1068 }
1069
1070 #[inline]
1071 fn line_run(&self, stone: &BitBoard, row: i32, col: i32, dr: i32, dc: i32) -> (u32, u32) {
1072 let mut count = 1u32;
1073 let mut open_ends = 0u32;
1074
1075 let mut r = row + dr;
1076 let mut c = col + dc;
1077 while in_board(r, c) && stone.get(to_idx(r as usize, c as usize)) {
1078 count += 1;
1079 r += dr;
1080 c += dc;
1081 }
1082 if in_board(r, c) && self.is_empty(to_idx(r as usize, c as usize)) {
1083 open_ends += 1;
1084 }
1085
1086 let mut r = row - dr;
1087 let mut c = col - dc;
1088 while in_board(r, c) && stone.get(to_idx(r as usize, c as usize)) {
1089 count += 1;
1090 r -= dr;
1091 c -= dc;
1092 }
1093 if in_board(r, c) && self.is_empty(to_idx(r as usize, c as usize)) {
1094 open_ends += 1;
1095 }
1096
1097 (count, open_ends)
1098 }
1099
1100 #[inline]
1101 pub fn is_legal_move(&self, mv: Move) -> bool {
1102 mv < NUM_CELLS && self.is_empty(mv)
1103 }
1104
1105 pub fn game_result(&self) -> GameResult {
1107 if let Some(mv) = self.last_move {
1108 if self.check_win(mv) {
1109 return match self.side_to_move {
1111 Stone::Black => GameResult::WhiteWin,
1112 Stone::White => GameResult::BlackWin,
1113 };
1114 }
1115 }
1116 if self.move_count >= NUM_CELLS {
1117 GameResult::Draw
1118 } else {
1119 GameResult::Ongoing
1120 }
1121 }
1122}
1123
1124impl fmt::Display for Board {
1125 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1126 write!(f, " ")?;
1127 for c in 0..BOARD_SIZE {
1128 write!(f, "{:2}", (b'A' + c as u8) as char)?;
1129 }
1130 writeln!(f)?;
1131
1132 for r in 0..BOARD_SIZE {
1133 write!(f, "{:2} ", r + 1)?;
1134 for c in 0..BOARD_SIZE {
1135 let idx = to_idx(r, c);
1136 if self.black.get(idx) {
1137 write!(f, " X")?;
1138 } else if self.white.get(idx) {
1139 write!(f, " O")?;
1140 } else {
1141 write!(f, " .")?;
1142 }
1143 }
1144 writeln!(f)?;
1145 }
1146 Ok(())
1147 }
1148}
1149
1150#[cfg(test)]
1151mod tests {
1152 use super::*;
1153
1154 fn put_stone(board: &mut Board, side: Stone, row: usize, col: usize) -> Move {
1155 let mv = to_idx(row, col);
1156 assert!(board.is_empty(mv));
1157 match side {
1158 Stone::Black => board.black.set(mv),
1159 Stone::White => board.white.set(mv),
1160 }
1161 board.move_count += 1;
1162 mv
1163 }
1164
1165 #[test]
1166 fn test_make_undo_move() {
1167 let mut board = Board::new();
1168 let mv = to_idx(7, 7);
1169 board.make_move(mv);
1170 assert!(board.black.get(mv));
1171 assert_eq!(board.side_to_move, Stone::White);
1172
1173 board.undo_move();
1174 assert!(!board.black.get(mv));
1175 assert_eq!(board.side_to_move, Stone::Black);
1176 assert_eq!(board.move_count, 0);
1177 }
1178
1179 #[test]
1181 fn zobrist_make_undo_roundtrip() {
1182 let mut board = Board::new();
1183 let initial = board.zobrist;
1184 assert_eq!(initial, 0, "empty board zobrist should be 0");
1185
1186 let moves = [112, 113, 97, 98, 127, 128, 200, 14];
1187 let mut keys = vec![initial];
1188 for &m in &moves {
1189 board.make_move(m);
1190 keys.push(board.zobrist);
1191 }
1192 let mut sorted = keys.clone();
1194 sorted.sort_unstable();
1195 sorted.dedup();
1196 assert_eq!(sorted.len(), keys.len(), "zobrist sequence collided");
1197
1198 for i in (1..keys.len()).rev() {
1200 board.undo_move();
1201 assert_eq!(
1202 board.zobrist,
1203 keys[i - 1],
1204 "zobrist mismatch after undo step {i}"
1205 );
1206 }
1207 assert_eq!(
1208 board.zobrist, 0,
1209 "zobrist did not return to 0 after full undo"
1210 );
1211 }
1212
1213 #[test]
1217 fn line_pattern_state_make_undo_consistency() {
1218 const DIRS: [(i32, i32); 4] = [(0, 1), (1, 0), (1, 1), (1, -1)];
1219 let moves = [112, 113, 97, 98, 127, 128, 200, 14, 0, 224, 7, 217, 50, 100];
1220
1221 let mut board = Board::new();
1222 let initial_ids = board.line_pattern_ids.clone();
1223
1224 for (i, &mv) in moves.iter().enumerate() {
1226 if !board.is_empty(mv) {
1227 continue;
1228 }
1229 board.make_move(mv);
1230
1231 let mut fresh = Board::new();
1233 for &m in &moves[..=i] {
1234 if fresh.is_empty(m) {
1235 fresh.make_move(m);
1236 }
1237 }
1238 for cell in 0..NUM_CELLS {
1242 let row = (cell / BOARD_SIZE) as i32;
1243 let col = (cell % BOARD_SIZE) as i32;
1244 for (dir_idx, &(dr, dc)) in DIRS.iter().enumerate() {
1245 let w = crate::pattern_table::read_window(
1246 &board.black,
1247 &board.white,
1248 row,
1249 col,
1250 dr,
1251 dc,
1252 );
1253 let packed = crate::pattern_table::pack_window(&w);
1254 let expected = crate::pattern_table::lookup_mapped_id(packed);
1255 let actual = board.line_pattern_ids[cell][dir_idx];
1256 assert_eq!(
1257 actual,
1258 expected,
1259 "mismatch at cell {} dir {} after move {} (ply {})",
1260 cell,
1261 dir_idx,
1262 mv,
1263 i + 1
1264 );
1265 }
1266 }
1267 }
1268
1269 for _ in 0..moves.len() {
1271 if !board.history.is_empty() {
1272 board.undo_move();
1273 }
1274 }
1275 assert_eq!(board.move_count, 0);
1276 for cell in 0..NUM_CELLS {
1278 for d in 0..4 {
1279 assert_eq!(
1280 board.line_pattern_ids[cell][d], initial_ids[cell][d],
1281 "after full undo: cell {} dir {} not restored",
1282 cell, d
1283 );
1284 }
1285 }
1286 }
1287
1288 fn assert_packed_windows_match_full_rebuild(
1289 board: &Board,
1290 state: &BoardSearchState,
1291 operation: usize,
1292 ) {
1293 const DIRS: [(i32, i32); 4] = [(0, 1), (1, 0), (1, 1), (1, -1)];
1294 assert!(state.packed_line_windows_enabled());
1295 assert!(state.is_synchronized(board));
1296 for cell in 0..NUM_CELLS {
1297 let row = (cell / BOARD_SIZE) as i32;
1298 let col = (cell % BOARD_SIZE) as i32;
1299 for (dir_idx, &(dr, dc)) in DIRS.iter().enumerate() {
1300 let window =
1301 crate::pattern_table::read_window(&board.black, &board.white, row, col, dr, dc);
1302 let expected_packed = crate::pattern_table::pack_window(&window);
1303 let actual_packed = state.packed_line_window(cell, dir_idx).unwrap();
1304 assert_eq!(
1305 actual_packed, expected_packed,
1306 "packed mismatch after operation {operation}, cell {cell}, dir {dir_idx}"
1307 );
1308 assert_eq!(
1309 board.line_pattern_ids[cell][dir_idx],
1310 crate::pattern_table::lookup_mapped_id(expected_packed),
1311 "mapped-id mismatch after operation {operation}, cell {cell}, dir {dir_idx}"
1312 );
1313 }
1314 }
1315 }
1316
1317 #[test]
1318 fn packed_line_windows_are_opt_in_for_library_callers_and_toggle_cleanly() {
1319 let mut board = Board::new();
1320 let mut state = BoardSearchState::new();
1321 assert!(!state.packed_line_windows_enabled());
1322 board.make_move(to_idx(7, 7));
1323 state.set_packed_line_windows_enabled(&board, true);
1324 assert_packed_windows_match_full_rebuild(&board, &state, 1);
1325 state.set_packed_line_windows_enabled(&board, false);
1326 assert!(!state.packed_line_windows_enabled());
1327 board.undo_move();
1328 let empty = Board::new();
1329 assert!(board.black == empty.black);
1330 assert!(board.white == empty.white);
1331 assert_eq!(board.side_to_move, empty.side_to_move);
1332 assert_eq!(board.move_count, 0);
1333 assert_eq!(board.line_pattern_ids, empty.line_pattern_ids);
1334 }
1335
1336 #[test]
1337 fn d4_hash_composes_with_existing_sidecars_and_make_undo() {
1338 let sequence = [
1339 to_idx(7, 7),
1340 to_idx(0, 0),
1341 to_idx(14, 14),
1342 to_idx(6, 8),
1343 to_idx(2, 12),
1344 to_idx(11, 3),
1345 ];
1346 let mut board = Board::new();
1347 let mut state = BoardSearchState::new();
1348 state.set_packed_line_windows_enabled(&board, true);
1349 state.set_candidate_frontier_enabled(&board, true);
1350 state.set_d4_hash_enabled(&board, true);
1351
1352 assert!(state.packed_line_windows_enabled());
1353 assert!(state.candidate_frontier_enabled());
1354 assert!(state.d4_hash_enabled());
1355 let root_hashes = state.d4_hashes(&board).expect("D4 hash enabled");
1356 assert_eq!(
1357 root_hashes,
1358 *crate::d4_hash::D4HashState::rebuild(&board).hashes()
1359 );
1360
1361 for &mv in &sequence {
1362 let predicted_hashes = state
1363 .d4_predicted_child_hashes(&board, mv)
1364 .expect("legal child prediction");
1365 let predicted_context = state
1366 .d4_predicted_child_context(&board, mv)
1367 .expect("legal child prediction");
1368 state.make_move(&mut board, mv);
1369 assert_eq!(state.d4_hashes(&board), Some(predicted_hashes));
1370 assert_eq!(state.d4_canonical_context(&board), Some(predicted_context));
1371 assert_eq!(
1372 state.d4_hashes(&board),
1373 Some(*crate::d4_hash::D4HashState::rebuild(&board).hashes())
1374 );
1375 assert_packed_windows_match_full_rebuild(&board, &state, board.move_count);
1376 assert_candidate_frontier_matches_full_rebuild(&board, &state, board.move_count);
1377 }
1378
1379 for operation in (0..sequence.len()).rev() {
1380 state.undo_move(&mut board);
1381 assert_eq!(
1382 state.d4_hashes(&board),
1383 Some(*crate::d4_hash::D4HashState::rebuild(&board).hashes()),
1384 "D4 hashes after undo {operation}"
1385 );
1386 }
1387 assert_eq!(state.d4_hashes(&board), Some(root_hashes));
1388 state.undo_move(&mut board);
1389 assert_eq!(
1390 state.d4_hashes(&board),
1391 Some(root_hashes),
1392 "empty-history undo must not toggle D4 hashes"
1393 );
1394 assert!(state.packed_line_windows_enabled());
1395 assert!(state.candidate_frontier_enabled());
1396 assert!(state.d4_hash_enabled());
1397 }
1398
1399 #[test]
1400 fn d4_hash_rule_change_forces_resync_and_preserves_all_enable_flags() {
1401 let mut board = Board::new();
1402 let mut state = BoardSearchState::new();
1403 state.set_packed_line_windows_enabled(&board, true);
1404 state.set_candidate_frontier_enabled(&board, true);
1405 state.set_d4_hash_enabled(&board, true);
1406 let freestyle = state.d4_hashes(&board).expect("D4 hash enabled");
1407
1408 board.set_rule_set(RuleSet::Caro);
1409 assert!(!state.is_synchronized(&board));
1410 assert_eq!(state.d4_hashes(&board), None);
1411 assert_eq!(state.d4_canonical_context(&board), None);
1412 state.synchronize(&board);
1413 let caro = state.d4_hashes(&board).expect("D4 hash rebuilt");
1414 assert_ne!(caro, freestyle);
1415 assert_eq!(caro, *crate::d4_hash::D4HashState::rebuild(&board).hashes());
1416 assert!(state.packed_line_windows_enabled());
1417 assert!(state.candidate_frontier_enabled());
1418 assert!(state.d4_hash_enabled());
1419
1420 board.set_rule_set(RuleSet::Freestyle);
1424 state.synchronize(&board);
1425 let freestyle_again = state.d4_hashes(&board).unwrap();
1426 board.exact5 = true;
1427 assert_eq!(board.effective_rule_set(), RuleSet::Standard);
1428 assert!(!state.is_synchronized(&board));
1429 state.synchronize(&board);
1430 let standard = state.d4_hashes(&board).unwrap();
1431 assert_ne!(standard, freestyle_again);
1432 assert!(state.packed_line_windows_enabled());
1433 assert!(state.candidate_frontier_enabled());
1434 assert!(state.d4_hash_enabled());
1435 }
1436
1437 #[test]
1441 #[ignore = "100k release audit; run explicitly with --release --ignored"]
1442 fn packed_line_windows_100k_make_undo_full_rebuild_equality() {
1443 const OPERATIONS: usize = 100_000;
1444 const FULL_REBUILD_PERIOD: usize = 97;
1445
1446 let mut legacy = Board::new();
1447 let mut packed = Board::new();
1448 let mut state = BoardSearchState::new();
1449 state.set_packed_line_windows_enabled(&packed, true);
1450 let mut rng = 0xDFA2_2026_0725_0001u64;
1451
1452 for operation in 1..=OPERATIONS {
1453 rng ^= rng << 13;
1454 rng ^= rng >> 7;
1455 rng ^= rng << 17;
1456 let should_undo =
1457 !legacy.history.is_empty() && (legacy.move_count >= 180 || (rng & 0b11) == 0);
1458
1459 if should_undo {
1460 legacy.undo_move();
1461 state.undo_move(&mut packed);
1462 } else {
1463 let start = (rng as usize) % NUM_CELLS;
1464 let mv = (0..NUM_CELLS)
1465 .map(|delta| (start + delta) % NUM_CELLS)
1466 .find(|&cell| legacy.is_empty(cell))
1467 .expect("at least one empty cell before make");
1468 legacy.make_move(mv);
1469 state.make_move(&mut packed, mv);
1470 }
1471
1472 assert!(
1473 packed.black == legacy.black,
1474 "black at operation {operation}"
1475 );
1476 assert!(
1477 packed.white == legacy.white,
1478 "white at operation {operation}"
1479 );
1480 assert_eq!(
1481 packed.side_to_move, legacy.side_to_move,
1482 "side at operation {operation}"
1483 );
1484 assert_eq!(
1485 packed.line_pattern_ids, legacy.line_pattern_ids,
1486 "mapped IDs at operation {operation}"
1487 );
1488
1489 if operation % FULL_REBUILD_PERIOD == 0 || operation == OPERATIONS {
1490 assert_packed_windows_match_full_rebuild(&packed, &state, operation);
1491 }
1492 }
1493
1494 while !legacy.history.is_empty() {
1495 legacy.undo_move();
1496 state.undo_move(&mut packed);
1497 }
1498 assert_eq!(packed.line_pattern_ids, legacy.line_pattern_ids);
1499 assert_packed_windows_match_full_rebuild(&packed, &state, OPERATIONS + 1);
1500 }
1501
1502 #[test]
1505 fn zobrist_path_independence() {
1506 let seq1 = [112, 113, 97, 98]; let _seq2 = [112, 98, 97, 113]; let seq2 = [97, 113, 112, 98];
1514
1515 let mut b1 = Board::new();
1516 for &m in &seq1 {
1517 b1.make_move(m);
1518 }
1519 let mut b2 = Board::new();
1520 for &m in &seq2 {
1521 b2.make_move(m);
1522 }
1523
1524 assert_eq!(b1.black.lo, b2.black.lo);
1525 assert_eq!(b1.black.hi, b2.black.hi);
1526 assert_eq!(b1.white.lo, b2.white.lo);
1527 assert_eq!(b1.white.hi, b2.white.hi);
1528 assert_eq!(b1.side_to_move, b2.side_to_move);
1529
1530 assert_eq!(
1531 b1.zobrist, b2.zobrist,
1532 "same position should have same zobrist"
1533 );
1534 }
1535
1536 #[test]
1537 fn test_horizontal_win() {
1538 let mut board = Board::new();
1539 for i in 0..5 {
1542 board.make_move(to_idx(7, 3 + i)); if i < 4 {
1544 board.make_move(to_idx(8, 3 + i)); }
1546 }
1547 assert_eq!(board.game_result(), GameResult::BlackWin);
1548 }
1549
1550 #[test]
1551 fn test_diagonal_win() {
1552 let mut board = Board::new();
1553 for i in 0..5 {
1556 board.make_move(to_idx(i, i)); if i < 4 {
1558 board.make_move(to_idx(i, i + 1)); }
1560 }
1561 assert_eq!(board.game_result(), GameResult::BlackWin);
1562 }
1563
1564 #[test]
1565 fn test_no_win_with_four() {
1566 let mut board = Board::new();
1567 for i in 0..4 {
1568 board.make_move(to_idx(7, 3 + i)); board.make_move(to_idx(8, 3 + i)); }
1571 assert_eq!(board.game_result(), GameResult::Ongoing);
1572 }
1573
1574 #[test]
1575 fn freestyle_overline_wins() {
1576 let mut board = Board::new();
1577 let mut last = 0;
1578 for col in 3..=8 {
1579 last = put_stone(&mut board, Stone::Black, 7, col);
1580 }
1581 assert!(board.check_win(last));
1582 }
1583
1584 #[test]
1585 fn standard_overline_does_not_win() {
1586 let mut board = Board::new();
1587 board.set_rule_set(RuleSet::Standard);
1588 let mut last = 0;
1589 for col in 3..=8 {
1590 last = put_stone(&mut board, Stone::Black, 7, col);
1591 }
1592 assert!(!board.check_win(last));
1593 }
1594
1595 #[test]
1596 fn standard_exact_five_wins() {
1597 let mut board = Board::new();
1598 board.set_rule_set(RuleSet::Standard);
1599 let mut last = 0;
1600 for col in 3..=7 {
1601 last = put_stone(&mut board, Stone::Black, 7, col);
1602 }
1603 assert!(board.check_win(last));
1604 }
1605
1606 #[test]
1607 fn caro_blocked_exact_five_does_not_win() {
1608 let mut board = Board::new();
1609 board.set_rule_set(RuleSet::Caro);
1610 put_stone(&mut board, Stone::White, 7, 3);
1611 put_stone(&mut board, Stone::White, 7, 9);
1612 let mut last = 0;
1613 for col in 4..=8 {
1614 last = put_stone(&mut board, Stone::Black, 7, col);
1615 }
1616 assert!(!board.check_win(last));
1617 }
1618
1619 #[test]
1620 fn caro_one_open_exact_five_wins() {
1621 let mut board = Board::new();
1622 board.set_rule_set(RuleSet::Caro);
1623 put_stone(&mut board, Stone::White, 7, 3);
1624 let mut last = 0;
1625 for col in 4..=8 {
1626 last = put_stone(&mut board, Stone::Black, 7, col);
1627 }
1628 assert!(board.check_win(last));
1629 }
1630
1631 #[test]
1632 fn caro_overline_wins_even_when_blocked() {
1633 let mut board = Board::new();
1634 board.set_rule_set(RuleSet::Caro);
1635 put_stone(&mut board, Stone::White, 7, 3);
1636 put_stone(&mut board, Stone::White, 7, 10);
1637 let mut last = 0;
1638 for col in 4..=9 {
1639 last = put_stone(&mut board, Stone::Black, 7, col);
1640 }
1641 assert!(board.check_win(last));
1642 }
1643
1644 #[test]
1645 fn test_candidate_moves_first() {
1646 let board = Board::new();
1647 let moves = board.candidate_moves();
1648 assert_eq!(moves, vec![to_idx(7, 7)]);
1649 }
1650
1651 fn assert_candidate_frontier_matches_full_rebuild(
1652 board: &Board,
1653 state: &BoardSearchState,
1654 operation: usize,
1655 ) {
1656 assert!(state.is_synchronized(board));
1657 let actual = state
1658 .candidate_frontier
1659 .as_ref()
1660 .expect("candidate frontier must be enabled");
1661 let rebuilt = board.rebuild_candidate_frontier();
1662 assert_eq!(
1663 actual.radius2_count, rebuilt.radius2_count,
1664 "radius2 counts at operation {operation}"
1665 );
1666 assert_eq!(
1667 actual.min_source, rebuilt.min_source,
1668 "minimum sources at operation {operation}"
1669 );
1670 assert!(
1671 actual.candidates == rebuilt.candidates,
1672 "candidate bitboard at operation {operation}"
1673 );
1674 assert!(
1675 actual.nonempty_sources == rebuilt.nonempty_sources,
1676 "non-empty source buckets at operation {operation}"
1677 );
1678 for source in 0..NUM_CELLS {
1679 assert!(
1680 actual.by_min_source[source] == rebuilt.by_min_source[source],
1681 "source bucket {source} at operation {operation}"
1682 );
1683 }
1684 assert_eq!(
1685 state.candidate_moves(board),
1686 if board.move_count == 0 {
1687 vec![to_idx(7, 7)]
1688 } else {
1689 board.candidate_moves_legacy()
1690 },
1691 "exact candidate order at operation {operation}"
1692 );
1693 }
1694
1695 #[test]
1696 fn candidate_frontier_is_opt_in_and_preserves_exact_order() {
1697 let sequence = [
1698 to_idx(7, 7),
1699 to_idx(0, 0),
1700 to_idx(14, 14),
1701 to_idx(7, 8),
1702 to_idx(2, 13),
1703 to_idx(12, 1),
1704 ];
1705 let mut board = Board::new();
1706 let mut state = BoardSearchState::new();
1707 assert!(!state.candidate_frontier_enabled());
1708 for &mv in &sequence {
1709 board.make_move(mv);
1710 }
1711 let expected = board.candidate_moves();
1712 state.set_candidate_frontier_enabled(&board, true);
1713 assert!(state.candidate_frontier_enabled());
1714 assert_eq!(state.candidate_moves(&board), expected);
1715 assert_candidate_frontier_matches_full_rebuild(&board, &state, sequence.len());
1716 state.set_candidate_frontier_enabled(&board, false);
1717 assert!(!state.candidate_frontier_enabled());
1718 assert_eq!(state.candidate_moves(&board), expected);
1719 }
1720
1721 #[test]
1725 #[ignore = "100k release audit; run explicitly with --release --ignored"]
1726 fn candidate_frontier_100k_make_undo_full_rebuild_equality() {
1727 const OPERATIONS: usize = 100_000;
1728 const FULL_REBUILD_PERIOD: usize = 97;
1729
1730 let mut legacy = Board::new();
1731 let mut incremental = Board::new();
1732 let mut state = BoardSearchState::new();
1733 state.set_packed_line_windows_enabled(&incremental, true);
1734 state.set_candidate_frontier_enabled(&incremental, true);
1735 let mut rng = 0xDFA3_2026_0725_0001u64;
1736
1737 for operation in 1..=OPERATIONS {
1738 rng ^= rng << 13;
1739 rng ^= rng >> 7;
1740 rng ^= rng << 17;
1741 let should_undo =
1742 !legacy.history.is_empty() && (legacy.move_count >= 180 || (rng & 0b11) == 0);
1743
1744 if should_undo {
1745 legacy.undo_move();
1746 state.undo_move(&mut incremental);
1747 } else {
1748 let start = (rng as usize) % NUM_CELLS;
1749 let mv = (0..NUM_CELLS)
1750 .map(|delta| (start + delta) % NUM_CELLS)
1751 .find(|&cell| legacy.is_empty(cell))
1752 .expect("at least one empty cell before make");
1753 legacy.make_move(mv);
1754 state.make_move(&mut incremental, mv);
1755 }
1756
1757 assert!(incremental.black == legacy.black);
1758 assert!(incremental.white == legacy.white);
1759 assert_eq!(incremental.side_to_move, legacy.side_to_move);
1760 assert_eq!(
1761 incremental.line_pattern_ids, legacy.line_pattern_ids,
1762 "mapped pattern IDs at operation {operation}"
1763 );
1764 assert_eq!(
1765 state.candidate_moves(&incremental),
1766 legacy.candidate_moves(),
1767 "ordered candidates at operation {operation}"
1768 );
1769
1770 if operation % FULL_REBUILD_PERIOD == 0 || operation == OPERATIONS {
1771 assert_packed_windows_match_full_rebuild(&incremental, &state, operation);
1772 assert_candidate_frontier_matches_full_rebuild(&incremental, &state, operation);
1773 }
1774 }
1775
1776 while !legacy.history.is_empty() {
1777 legacy.undo_move();
1778 state.undo_move(&mut incremental);
1779 assert_eq!(
1780 state.candidate_moves(&incremental),
1781 legacy.candidate_moves()
1782 );
1783 }
1784 assert_packed_windows_match_full_rebuild(&incremental, &state, OPERATIONS + 1);
1785 assert_candidate_frontier_matches_full_rebuild(&incremental, &state, OPERATIONS + 1);
1786 }
1787}