1use std::fmt;
6
7#[cfg(not(feature = "board20"))]
9pub const BOARD_SIZE: usize = 15;
10#[cfg(feature = "board20")]
11pub const BOARD_SIZE: usize = 20;
12#[cfg(not(feature = "board20"))]
14pub type CellIdx = u8;
15#[cfg(feature = "board20")]
16pub type CellIdx = u16;
17pub const BITBOARD_WORDS: usize = NUM_CELLS.div_ceil(128);
19pub const NUM_CELLS: usize = BOARD_SIZE * BOARD_SIZE; pub const LINE_PATTERN_FRONTIER_MAX: usize = 41;
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum Stone {
24 Black,
25 White,
26}
27
28impl Stone {
29 pub fn opponent(self) -> Stone {
30 match self {
31 Stone::Black => Stone::White,
32 Stone::White => Stone::Black,
33 }
34 }
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum RuleSet {
39 Freestyle,
40 Standard,
41 Caro,
42 Renju,
45 Omok,
49}
50
51impl RuleSet {
52 #[inline]
53 pub const fn uses_exact_five(self) -> bool {
54 matches!(self, RuleSet::Standard | RuleSet::Renju | RuleSet::Omok)
55 }
56
57 #[inline]
61 pub const fn line_wins(self, side: Stone, count: u32, open_ends: u32) -> bool {
62 self.line_wins_with_edges(side, count, open_ends, 0)
63 }
64
65 #[inline]
70 pub const fn line_wins_with_edges(
71 self,
72 side: Stone,
73 count: u32,
74 open_ends: u32,
75 edge_ends: u32,
76 ) -> bool {
77 match self {
78 RuleSet::Freestyle => count >= 5,
79 RuleSet::Standard | RuleSet::Omok => count == 5,
80 RuleSet::Caro => count == 5 && open_ends + edge_ends > 0,
81 RuleSet::Renju => match side {
82 Stone::Black => count == 5,
83 Stone::White => count >= 5,
84 },
85 }
86 }
87}
88
89#[cfg(not(feature = "board20"))]
92#[derive(Clone, Copy, PartialEq, Eq)]
93pub struct BitBoard {
94 pub lo: u128,
95 pub hi: u128,
96}
97
98#[cfg(feature = "board20")]
100#[derive(Clone, Copy, PartialEq, Eq)]
101pub struct BitBoard {
102 pub w: [u128; BITBOARD_WORDS],
103}
104
105const CANDIDATE_NEIGHBOURS: [BitBoard; NUM_CELLS] = {
108 let mut masks = [BitBoard::EMPTY; NUM_CELLS];
109 let mut cell = 0;
110 while cell < NUM_CELLS {
111 let mut row = 0;
112 while row < BOARD_SIZE {
113 let mut col = 0;
114 while col < BOARD_SIZE {
115 if (row as i32 - (cell / BOARD_SIZE) as i32).abs() <= 2
116 && (col as i32 - (cell % BOARD_SIZE) as i32).abs() <= 2
117 {
118 masks[cell] = masks[cell].with(row * BOARD_SIZE + col);
119 }
120 col += 1;
121 }
122 row += 1;
123 }
124 cell += 1;
125 }
126 masks
127};
128
129#[cfg(not(feature = "board20"))]
130impl BitBoard {
131 pub const EMPTY: Self = Self { lo: 0, hi: 0 };
132
133 #[inline]
135 pub const fn words(&self) -> [u128; BITBOARD_WORDS] {
136 [self.lo, self.hi]
137 }
138
139 pub const fn with(self, idx: usize) -> Self {
141 if idx < 128 {
142 Self { lo: self.lo | 1u128 << idx, hi: self.hi }
143 } else {
144 Self { lo: self.lo, hi: self.hi | 1u128 << (idx - 128) }
145 }
146 }
147
148 #[inline]
149 pub fn and(&self, other: &BitBoard) -> BitBoard {
150 BitBoard { lo: self.lo & other.lo, hi: self.hi & other.hi }
151 }
152
153 #[inline]
154 pub fn intersects(&self, other: &BitBoard) -> bool {
155 (self.lo & other.lo) != 0 || (self.hi & other.hi) != 0
156 }
157
158 #[inline]
159 pub fn and_not(&self, other: &BitBoard) -> BitBoard {
160 BitBoard { lo: self.lo & !other.lo, hi: self.hi & !other.hi }
161 }
162
163 #[inline]
164 pub fn is_zero(&self) -> bool {
165 self.lo == 0 && self.hi == 0
166 }
167
168 #[inline]
169 pub fn get(&self, idx: usize) -> bool {
170 if idx < 128 {
171 (self.lo >> idx) & 1 != 0
172 } else {
173 (self.hi >> (idx - 128)) & 1 != 0
174 }
175 }
176
177 #[inline]
178 pub fn set(&mut self, idx: usize) {
179 if idx < 128 {
180 self.lo |= 1u128 << idx;
181 } else {
182 self.hi |= 1u128 << (idx - 128);
183 }
184 }
185
186 #[inline]
187 pub fn clear(&mut self, idx: usize) {
188 if idx < 128 {
189 self.lo &= !(1u128 << idx);
190 } else {
191 self.hi &= !(1u128 << (idx - 128));
192 }
193 }
194
195 #[inline]
196 pub fn or(&self, other: &BitBoard) -> BitBoard {
197 BitBoard {
198 lo: self.lo | other.lo,
199 hi: self.hi | other.hi,
200 }
201 }
202
203 #[inline]
204 pub fn count_ones(&self) -> u32 {
205 self.lo.count_ones() + self.hi.count_ones()
206 }
207
208 #[inline]
213 pub fn iter_ones(&self) -> BitBoardIter {
214 BitBoardIter {
215 lo: self.lo,
216 hi: self.hi,
217 }
218 }
219}
220
221#[cfg(feature = "board20")]
222impl BitBoard {
223 pub const EMPTY: Self = Self { w: [0; BITBOARD_WORDS] };
224
225 #[inline]
227 pub const fn words(&self) -> [u128; BITBOARD_WORDS] {
228 self.w
229 }
230
231 pub const fn with(self, idx: usize) -> Self {
233 let mut w = self.w;
234 w[idx >> 7] |= 1u128 << (idx & 127);
235 Self { w }
236 }
237
238 #[inline]
239 pub fn get(&self, idx: usize) -> bool {
240 (self.w[idx >> 7] >> (idx & 127)) & 1 != 0
241 }
242
243 #[inline]
244 pub fn set(&mut self, idx: usize) {
245 self.w[idx >> 7] |= 1u128 << (idx & 127);
246 }
247
248 #[inline]
249 pub fn clear(&mut self, idx: usize) {
250 self.w[idx >> 7] &= !(1u128 << (idx & 127));
251 }
252
253 #[inline]
254 pub fn or(&self, other: &BitBoard) -> BitBoard {
255 BitBoard { w: std::array::from_fn(|i| self.w[i] | other.w[i]) }
256 }
257
258 #[inline]
259 pub fn and(&self, other: &BitBoard) -> BitBoard {
260 BitBoard { w: std::array::from_fn(|i| self.w[i] & other.w[i]) }
261 }
262
263 #[inline]
264 pub fn intersects(&self, other: &BitBoard) -> bool {
265 self.w.iter().zip(other.w.iter()).any(|(a, b)| a & b != 0)
266 }
267
268 #[inline]
269 pub fn and_not(&self, other: &BitBoard) -> BitBoard {
270 BitBoard { w: std::array::from_fn(|i| self.w[i] & !other.w[i]) }
271 }
272
273 #[inline]
274 pub fn is_zero(&self) -> bool {
275 self.w.iter().all(|&x| x == 0)
276 }
277
278 #[inline]
279 pub fn count_ones(&self) -> u32 {
280 self.w.iter().map(|x| x.count_ones()).sum()
281 }
282
283 #[inline]
285 pub fn iter_ones(&self) -> BitBoardIter {
286 BitBoardIter { w: self.w, i: 0 }
287 }
288}
289
290#[cfg(feature = "board20")]
291pub struct BitBoardIter {
292 w: [u128; BITBOARD_WORDS],
293 i: usize,
294}
295
296#[cfg(feature = "board20")]
297impl Iterator for BitBoardIter {
298 type Item = usize;
299 #[inline]
300 fn next(&mut self) -> Option<usize> {
301 while self.i < BITBOARD_WORDS {
302 let word = &mut self.w[self.i];
303 if *word != 0 {
304 let bit = word.trailing_zeros() as usize;
305 *word &= *word - 1;
306 return Some(self.i * 128 + bit);
307 }
308 self.i += 1;
309 }
310 None
311 }
312}
313
314#[cfg(not(feature = "board20"))]
315pub struct BitBoardIter {
316 lo: u128,
317 hi: u128,
318}
319
320#[cfg(not(feature = "board20"))]
321impl Iterator for BitBoardIter {
322 type Item = usize;
323 #[inline]
324 fn next(&mut self) -> Option<usize> {
325 if self.lo != 0 {
326 let idx = self.lo.trailing_zeros() as usize;
327 self.lo &= self.lo - 1;
328 Some(idx)
329 } else if self.hi != 0 {
330 let idx = 128 + self.hi.trailing_zeros() as usize;
331 self.hi &= self.hi - 1;
332 Some(idx)
333 } else {
334 None
335 }
336 }
337}
338
339#[derive(Debug, Clone, Copy, PartialEq, Eq)]
340pub enum GameResult {
341 BlackWin,
342 WhiteWin,
343 Draw,
344 Ongoing,
345}
346
347pub type Move = usize;
349
350#[inline]
351pub fn to_rc(idx: usize) -> (usize, usize) {
352 (idx / BOARD_SIZE, idx % BOARD_SIZE)
353}
354
355#[inline]
356pub fn to_idx(row: usize, col: usize) -> usize {
357 row * BOARD_SIZE + col
358}
359
360#[inline]
361fn in_board(row: i32, col: i32) -> bool {
362 row >= 0 && row < BOARD_SIZE as i32 && col >= 0 && col < BOARD_SIZE as i32
363}
364
365mod zobrist {
369 use super::{NUM_CELLS, Stone};
370
371 pub(super) const fn splitmix64(seed: u64) -> u64 {
373 let mut x = seed;
374 x = x.wrapping_add(0x9E3779B97F4A7C15);
375 x = (x ^ (x >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
376 x = (x ^ (x >> 27)).wrapping_mul(0x94D049BB133111EB);
377 x ^ (x >> 31)
378 }
379
380 const fn build_keys() -> [[u64; NUM_CELLS]; 2] {
381 let mut out = [[0u64; NUM_CELLS]; 2];
382 let mut color = 0;
383 while color < 2 {
384 let mut cell = 0;
385 while cell < NUM_CELLS {
386 let seed = (color as u64) * 0x9E3779B97F4A7C15 ^ (cell as u64);
387 out[color][cell] = splitmix64(seed);
388 cell += 1;
389 }
390 color += 1;
391 }
392 out
393 }
394
395 pub const STONE_KEYS: [[u64; NUM_CELLS]; 2] = build_keys();
396 pub const SIDE_TO_MOVE_KEY: u64 = splitmix64(0xCAFE_BABE_DEAD_BEEF);
397
398 #[inline]
399 pub const fn key_for(stone: Stone, cell: usize) -> u64 {
400 let color = match stone {
401 Stone::Black => 0,
402 Stone::White => 1,
403 };
404 STONE_KEYS[color][cell]
405 }
406}
407
408pub use zobrist::SIDE_TO_MOVE_KEY as ZOBRIST_SIDE;
409
410#[inline]
411pub const fn zobrist_stone_key(stone: Stone, cell: usize) -> u64 {
412 zobrist::key_for(stone, cell)
413}
414
415#[inline]
421pub const fn d4_rule_key(rule: RuleSet) -> u64 {
422 let seed = match rule {
423 RuleSet::Freestyle => 0xD4C0_0000_0000_0000,
424 RuleSet::Standard => 0xD4C0_0000_0000_0001,
425 RuleSet::Caro => 0xD4C0_0000_0000_0002,
426 RuleSet::Renju => 0xD4C0_0000_0000_0003,
427 RuleSet::Omok => 0xD4C0_0000_0000_0004,
428 };
429 zobrist::splitmix64(seed)
430}
431
432pub type LinePatternState = Box<[[u16; 4]; NUM_CELLS]>;
440
441const NO_CANDIDATE_SOURCE: CellIdx = CellIdx::MAX;
442
443#[derive(Clone)]
449struct CandidateFrontierState {
450 radius2_count: [u8; NUM_CELLS],
451 candidates: BitBoard,
452 min_source: [CellIdx; NUM_CELLS],
453 by_min_source: [BitBoard; NUM_CELLS],
454 nonempty_sources: BitBoard,
455}
456
457impl CandidateFrontierState {
458 fn empty() -> Self {
459 Self {
460 radius2_count: [0; NUM_CELLS],
461 candidates: BitBoard::EMPTY,
462 min_source: [NO_CANDIDATE_SOURCE; NUM_CELLS],
463 by_min_source: [BitBoard::EMPTY; NUM_CELLS],
464 nonempty_sources: BitBoard::EMPTY,
465 }
466 }
467
468 #[inline]
469 fn bucket_insert(&mut self, source: usize, cell: usize) {
470 let bucket = &mut self.by_min_source[source];
471 let was_empty = bucket.is_zero();
472 bucket.set(cell);
473 if was_empty {
474 self.nonempty_sources.set(source);
475 }
476 }
477
478 #[inline]
479 fn bucket_remove(&mut self, source: usize, cell: usize) {
480 let bucket = &mut self.by_min_source[source];
481 debug_assert!(bucket.get(cell));
482 bucket.clear(cell);
483 if bucket.is_zero() {
484 self.nonempty_sources.clear(source);
485 }
486 }
487
488 #[inline]
489 fn candidate_insert(&mut self, source: usize, cell: usize) {
490 debug_assert!(!self.candidates.get(cell));
491 debug_assert_eq!(self.min_source[cell], NO_CANDIDATE_SOURCE);
492 self.candidates.set(cell);
493 self.min_source[cell] = source as CellIdx;
494 self.bucket_insert(source, cell);
495 }
496
497 #[inline]
498 fn candidate_remove(&mut self, cell: usize) {
499 debug_assert!(self.candidates.get(cell));
500 let source = self.min_source[cell] as usize;
501 debug_assert!(source < NUM_CELLS);
502 self.bucket_remove(source, cell);
503 self.candidates.clear(cell);
504 self.min_source[cell] = NO_CANDIDATE_SOURCE;
505 }
506
507 #[inline]
508 fn candidate_rekey(&mut self, new_source: usize, cell: usize) {
509 debug_assert!(self.candidates.get(cell));
510 let old_source = self.min_source[cell] as usize;
511 debug_assert!(old_source < NUM_CELLS);
512 self.bucket_remove(old_source, cell);
513 self.min_source[cell] = new_source as CellIdx;
514 self.bucket_insert(new_source, cell);
515 }
516}
517
518pub type LinePackedWindowState = Box<[[u32; 4]; NUM_CELLS]>;
523
524#[doc(hidden)]
531#[derive(Clone, Default)]
532pub struct BoardSearchState {
533 line_packed_windows: Option<LinePackedWindowState>,
534 candidate_frontier: Option<Box<CandidateFrontierState>>,
535 d4_hash: Option<crate::d4_hash::D4HashState>,
536 synchronized_position: Option<(u64, usize, RuleSet)>,
537}
538
539impl BoardSearchState {
540 pub fn new() -> Self {
541 Self::default()
542 }
543
544 #[inline]
546 pub fn is_synchronized(&self, board: &Board) -> bool {
547 if self.line_packed_windows.is_none()
548 && self.candidate_frontier.is_none()
549 && self.d4_hash.is_none()
550 {
551 true
552 } else {
553 self.synchronized_position
554 == Some((board.zobrist, board.move_count, board.effective_rule_set()))
555 }
556 }
557
558 pub fn synchronize(&mut self, board: &Board) {
561 if self.is_synchronized(board) {
562 return;
563 }
564 let packed_enabled = self.line_packed_windows.is_some();
565 let frontier_enabled = self.candidate_frontier.is_some();
566 let d4_hash_enabled = self.d4_hash.is_some();
567 self.line_packed_windows = None;
568 self.candidate_frontier = None;
569 self.d4_hash = None;
570 self.synchronized_position = None;
571 if packed_enabled {
572 self.set_packed_line_windows_enabled(board, true);
573 }
574 if frontier_enabled {
575 self.set_candidate_frontier_enabled(board, true);
576 }
577 if d4_hash_enabled {
578 self.set_d4_hash_enabled(board, true);
579 }
580 }
581
582 #[inline]
583 fn record_position(&mut self, board: &Board) {
584 self.synchronized_position = (self.line_packed_windows.is_some()
585 || self.candidate_frontier.is_some()
586 || self.d4_hash.is_some())
587 .then_some((board.zobrist, board.move_count, board.effective_rule_set()));
588 }
589
590 pub fn set_packed_line_windows_enabled(&mut self, board: &Board, enabled: bool) {
596 self.synchronize(board);
597 if enabled {
598 if self.line_packed_windows.is_none() {
599 let mut windows = Box::new([[0u32; 4]; NUM_CELLS]);
600 const DIRS: [(i32, i32); 4] = [(0, 1), (1, 0), (1, 1), (1, -1)];
601 for cell in 0..NUM_CELLS {
602 let row = (cell / BOARD_SIZE) as i32;
603 let col = (cell % BOARD_SIZE) as i32;
604 for (dir_idx, &(dr, dc)) in DIRS.iter().enumerate() {
605 let window = crate::pattern_table::read_window(
606 &board.black,
607 &board.white,
608 row,
609 col,
610 dr,
611 dc,
612 );
613 let packed = crate::pattern_table::pack_window(&window);
614 debug_assert_eq!(
615 board.line_pattern_ids[cell][dir_idx],
616 crate::pattern_table::lookup_mapped_id(packed),
617 "line pattern ID stale before packed-window enable"
618 );
619 windows[cell][dir_idx] = packed;
620 }
621 }
622 self.line_packed_windows = Some(windows);
623 }
624 } else {
625 self.line_packed_windows = None;
626 }
627 self.record_position(board);
628 }
629
630 #[inline]
631 pub fn packed_line_windows_enabled(&self) -> bool {
632 self.line_packed_windows.is_some()
633 }
634
635 #[inline]
637 pub fn packed_line_window(&self, cell: usize, dir_idx: usize) -> Option<u32> {
638 self.line_packed_windows
639 .as_ref()
640 .map(|windows| windows[cell][dir_idx])
641 }
642
643 pub fn set_candidate_frontier_enabled(&mut self, board: &Board, enabled: bool) {
647 self.synchronize(board);
648 if enabled {
649 if self.candidate_frontier.is_none() {
650 self.candidate_frontier = Some(board.rebuild_candidate_frontier());
651 }
652 } else {
653 self.candidate_frontier = None;
654 }
655 self.record_position(board);
656 }
657
658 #[inline]
659 pub fn candidate_frontier_enabled(&self) -> bool {
660 self.candidate_frontier.is_some()
661 }
662
663 #[doc(hidden)]
668 pub fn set_d4_hash_enabled(&mut self, board: &Board, enabled: bool) {
669 self.synchronize(board);
670 if enabled {
671 if self.d4_hash.is_none() {
672 self.d4_hash = Some(crate::d4_hash::D4HashState::rebuild(board));
673 }
674 } else {
675 self.d4_hash = None;
676 }
677 self.record_position(board);
678 }
679
680 #[doc(hidden)]
681 #[inline]
682 pub fn d4_hash_enabled(&self) -> bool {
683 self.d4_hash.is_some()
684 }
685
686 #[doc(hidden)]
689 #[inline]
690 pub fn d4_hashes(&self, board: &Board) -> Option<[u64; 8]> {
691 if !self.is_synchronized(board) {
692 return None;
693 }
694 self.d4_hash.as_ref().map(|state| *state.hashes())
695 }
696
697 #[doc(hidden)]
699 #[inline]
700 pub fn d4_canonical_context(&self, board: &Board) -> Option<crate::d4_hash::CanonicalContext> {
701 if !self.is_synchronized(board) {
702 return None;
703 }
704 self.d4_hash
705 .as_ref()
706 .map(crate::d4_hash::D4HashState::canonical_context)
707 }
708
709 #[doc(hidden)]
711 #[inline]
712 pub fn d4_predicted_child_hashes(&self, board: &Board, mv: Move) -> Option<[u64; 8]> {
713 if !self.is_synchronized(board) {
714 return None;
715 }
716 self.d4_hash.as_ref()?.predicted_child_hashes(board, mv)
717 }
718
719 #[doc(hidden)]
721 #[inline]
722 pub fn d4_predicted_child_context(
723 &self,
724 board: &Board,
725 mv: Move,
726 ) -> Option<crate::d4_hash::CanonicalContext> {
727 if !self.is_synchronized(board) {
728 return None;
729 }
730 self.d4_hash.as_ref()?.predicted_child_context(board, mv)
731 }
732
733 pub fn candidate_moves(&self, board: &Board) -> Vec<Move> {
735 if !self.is_synchronized(board) {
736 return board.candidate_moves();
737 }
738 self.candidate_moves_synchronized(board)
739 }
740
741 #[inline]
742 pub(crate) fn candidate_moves_synchronized(&self, board: &Board) -> Vec<Move> {
743 debug_assert!(
744 self.is_synchronized(board),
745 "BoardSearchState is stale for candidate generation"
746 );
747 if board.move_count == 0 {
748 return vec![to_idx(BOARD_SIZE / 2, BOARD_SIZE / 2)];
749 }
750 let Some(frontier) = self.candidate_frontier.as_ref() else {
751 return board.candidate_moves();
752 };
753 board.candidate_moves_from_frontier(frontier)
754 }
755
756 #[inline]
758 pub fn make_move(&mut self, board: &mut Board, mv: Move) {
759 self.synchronize(board);
760 self.make_move_synchronized(board, mv);
761 }
762
763 #[inline]
764 pub(crate) fn make_move_synchronized(&mut self, board: &mut Board, mv: Move) {
765 debug_assert!(
766 self.is_synchronized(board),
767 "BoardSearchState is stale before make_move"
768 );
769 let placed = board.side_to_move;
770 board.make_move_with_search_state(
771 mv,
772 self.line_packed_windows.as_deref_mut(),
773 self.candidate_frontier.as_deref_mut(),
774 );
775 if let Some(d4_hash) = self.d4_hash.as_mut() {
776 d4_hash.apply_move(placed, mv);
777 }
778 self.record_position(board);
779 }
780
781 #[inline]
783 pub fn undo_move(&mut self, board: &mut Board) {
784 self.synchronize(board);
785 self.undo_move_synchronized(board);
786 }
787
788 #[inline]
789 pub(crate) fn undo_move_synchronized(&mut self, board: &mut Board) {
790 debug_assert!(
791 self.is_synchronized(board),
792 "BoardSearchState is stale before undo_move"
793 );
794 let removed = board
795 .history
796 .last()
797 .copied()
798 .map(|mv| (board.side_to_move.opponent(), mv));
799 board.undo_move_with_search_state(
800 self.line_packed_windows.as_deref_mut(),
801 self.candidate_frontier.as_deref_mut(),
802 );
803 if let (Some(d4_hash), Some((placed, mv))) = (self.d4_hash.as_mut(), removed) {
804 d4_hash.apply_move(placed, mv);
805 }
806 self.record_position(board);
807 }
808}
809
810#[derive(Clone)]
811pub struct Board {
812 pub black: BitBoard,
813 pub white: BitBoard,
814 pub side_to_move: Stone,
815 pub move_count: usize,
816 pub last_move: Option<Move>,
817 pub history: Vec<Move>,
819 pub zobrist: u64,
823 pub line_pattern_ids: LinePatternState,
828 pub rule_set: RuleSet,
830 pub exact5: bool,
833}
834
835impl Board {
836 pub fn new() -> Self {
837 let mut b = Self {
838 black: BitBoard::EMPTY,
839 white: BitBoard::EMPTY,
840 side_to_move: Stone::Black,
841 move_count: 0,
842 last_move: None,
843 history: Vec::with_capacity(NUM_CELLS),
844 zobrist: 0,
846 rule_set: RuleSet::Freestyle,
849 line_pattern_ids: Box::new([[0u16; 4]; NUM_CELLS]),
850 exact5: false,
851 };
852 b.fill_initial_pattern_ids();
853 b
854 }
855
856 #[inline]
857 pub fn set_rule_set(&mut self, rule_set: RuleSet) {
858 self.rule_set = rule_set;
859 self.exact5 = rule_set.uses_exact_five();
860 }
861
862 #[inline]
863 pub fn effective_rule_set(&self) -> RuleSet {
864 if self.exact5 && matches!(self.rule_set, RuleSet::Freestyle) {
867 RuleSet::Standard
868 } else {
869 self.rule_set
870 }
871 }
872
873 fn fill_initial_pattern_ids(&mut self) {
877 const DIRS: [(i32, i32); 4] = [(0, 1), (1, 0), (1, 1), (1, -1)];
878 for cell in 0..NUM_CELLS {
879 let row = (cell / BOARD_SIZE) as i32;
880 let col = (cell % BOARD_SIZE) as i32;
881 for (dir_idx, &(dr, dc)) in DIRS.iter().enumerate() {
882 let w =
883 crate::pattern_table::read_window(&self.black, &self.white, row, col, dr, dc);
884 let packed = crate::pattern_table::pack_window(&w);
885 self.line_pattern_ids[cell][dir_idx] =
886 crate::pattern_table::lookup_mapped_id(packed);
887 }
888 }
889 }
890
891 #[inline]
893 pub fn is_empty(&self, idx: usize) -> bool {
894 let occupied = self.black.or(&self.white);
895 !occupied.get(idx)
896 }
897
898 #[inline]
900 pub fn current_stones(&self) -> &BitBoard {
901 match self.side_to_move {
902 Stone::Black => &self.black,
903 Stone::White => &self.white,
904 }
905 }
906
907 #[inline]
909 pub fn opponent_stones(&self) -> &BitBoard {
910 match self.side_to_move {
911 Stone::Black => &self.white,
912 Stone::White => &self.black,
913 }
914 }
915
916 pub fn legal_moves(&self) -> Vec<Move> {
918 let occupied = self.black.or(&self.white);
919 let mut moves = Vec::with_capacity(NUM_CELLS - self.move_count);
920 for idx in 0..NUM_CELLS {
921 if !occupied.get(idx) {
922 moves.push(idx);
923 }
924 }
925 moves
926 }
927
928 #[inline]
929 fn for_radius2_neighbors(cell: usize, mut f: impl FnMut(usize)) {
930 let (row, col) = to_rc(cell);
931 let row_start = row.saturating_sub(2);
932 let row_end = (row + 2).min(BOARD_SIZE - 1);
933 let col_start = col.saturating_sub(2);
934 let col_end = (col + 2).min(BOARD_SIZE - 1);
935 for neighbor_row in row_start..=row_end {
936 for neighbor_col in col_start..=col_end {
937 let neighbor = to_idx(neighbor_row, neighbor_col);
938 if neighbor != cell {
939 f(neighbor);
940 }
941 }
942 }
943 }
944
945 #[inline]
946 fn first_radius2_source(occupied: &BitBoard, cell: usize) -> Option<usize> {
947 let mut first = None;
948 Self::for_radius2_neighbors(cell, |neighbor| {
949 if first.is_none() && occupied.get(neighbor) {
950 first = Some(neighbor);
951 }
952 });
953 first
954 }
955
956 fn rebuild_candidate_frontier(&self) -> Box<CandidateFrontierState> {
957 let occupied = self.black.or(&self.white);
958 let mut state = Box::new(CandidateFrontierState::empty());
959 for source in occupied.iter_ones() {
960 Self::for_radius2_neighbors(source, |cell| {
961 state.radius2_count[cell] += 1;
962 });
963 }
964 for cell in 0..NUM_CELLS {
965 if !occupied.get(cell) && state.radius2_count[cell] > 0 {
966 let source = Self::first_radius2_source(&occupied, cell)
967 .expect("positive radius-2 count must have a source");
968 state.candidate_insert(source, cell);
969 }
970 }
971 state
972 }
973
974 #[cfg(test)]
975 fn candidate_moves_legacy(&self) -> Vec<Move> {
976 let occupied = self.black.or(&self.white);
977 let mut seen = [false; NUM_CELLS];
978 let mut moves = Vec::with_capacity(64);
979
980 for idx in 0..NUM_CELLS {
981 if !occupied.get(idx) {
982 continue;
983 }
984 let (r, c) = to_rc(idx);
985 for dr in -2i32..=2 {
986 for dc in -2i32..=2 {
987 if dr == 0 && dc == 0 {
988 continue;
989 }
990 let nr = r as i32 + dr;
991 let nc = c as i32 + dc;
992 if nr < 0 || nr >= BOARD_SIZE as i32 || nc < 0 || nc >= BOARD_SIZE as i32 {
993 continue;
994 }
995 let nidx = to_idx(nr as usize, nc as usize);
996 if !seen[nidx] && !occupied.get(nidx) {
997 seen[nidx] = true;
998 moves.push(nidx);
999 }
1000 }
1001 }
1002 }
1003
1004 moves
1005 }
1006
1007 pub fn candidate_moves(&self) -> Vec<Move> {
1009 if self.move_count == 0 {
1010 return vec![to_idx(BOARD_SIZE / 2, BOARD_SIZE / 2)];
1012 }
1013 let occupied = self.black.or(&self.white);
1014 let mut seen = occupied;
1015 let mut moves = Vec::with_capacity(64);
1016 for cell in occupied.iter_ones() {
1017 let mask = CANDIDATE_NEIGHBOURS[cell];
1018 moves.extend(mask.and_not(&seen).iter_ones());
1019 seen = seen.or(&mask);
1020 }
1021 moves
1022 }
1023
1024 fn candidate_moves_from_frontier(&self, frontier: &CandidateFrontierState) -> Vec<Move> {
1025 let mut moves = Vec::with_capacity(frontier.candidates.count_ones() as usize);
1026 for source in frontier.nonempty_sources.iter_ones() {
1027 moves.extend(frontier.by_min_source[source].iter_ones());
1028 }
1029 debug_assert_eq!(moves.len(), frontier.candidates.count_ones() as usize);
1030 moves
1031 }
1032
1033 #[inline]
1034 fn update_candidate_frontier_after_make(
1035 &self,
1036 frontier: &mut CandidateFrontierState,
1037 mv: Move,
1038 ) {
1039 let occupied = self.black.or(&self.white);
1040
1041 if frontier.candidates.get(mv) {
1042 frontier.candidate_remove(mv);
1043 }
1044 Self::for_radius2_neighbors(mv, |cell| {
1045 let old_count = frontier.radius2_count[cell];
1046 debug_assert!(old_count < 24);
1047 frontier.radius2_count[cell] = old_count + 1;
1048 if occupied.get(cell) {
1049 return;
1050 }
1051 if old_count == 0 {
1052 frontier.candidate_insert(mv, cell);
1053 } else {
1054 let old_source = frontier.min_source[cell] as usize;
1055 debug_assert!(old_source < NUM_CELLS);
1056 if mv < old_source {
1057 frontier.candidate_rekey(mv, cell);
1058 }
1059 }
1060 });
1061 }
1062
1063 #[inline]
1064 fn update_candidate_frontier_after_undo(
1065 &self,
1066 frontier: &mut CandidateFrontierState,
1067 mv: Move,
1068 ) {
1069 let occupied = self.black.or(&self.white);
1070
1071 Self::for_radius2_neighbors(mv, |cell| {
1072 let old_count = frontier.radius2_count[cell];
1073 debug_assert!(old_count > 0);
1074 frontier.radius2_count[cell] = old_count - 1;
1075 if occupied.get(cell) {
1076 return;
1077 }
1078 if old_count == 1 {
1079 frontier.candidate_remove(cell);
1080 } else if frontier.min_source[cell] as usize == mv {
1081 let new_source = Self::first_radius2_source(&occupied, cell)
1082 .expect("remaining radius-2 count must have a source");
1083 frontier.candidate_rekey(new_source, cell);
1084 }
1085 });
1086
1087 if frontier.radius2_count[mv] > 0 {
1088 let source = Self::first_radius2_source(&occupied, mv)
1089 .expect("newly empty candidate must have a radius-2 source");
1090 frontier.candidate_insert(source, mv);
1091 }
1092 }
1093
1094 pub fn make_move(&mut self, mv: Move) {
1096 self.make_move_with_search_state(mv, None, None);
1097 }
1098
1099 #[inline]
1100 fn make_move_with_search_state(
1101 &mut self,
1102 mv: Move,
1103 line_packed_windows: Option<&mut [[u32; 4]; NUM_CELLS]>,
1104 candidate_frontier: Option<&mut CandidateFrontierState>,
1105 ) {
1106 debug_assert!(mv < NUM_CELLS);
1107 debug_assert!(self.is_empty(mv));
1108
1109 let placed = self.side_to_move;
1110 match placed {
1111 Stone::Black => self.black.set(mv),
1112 Stone::White => self.white.set(mv),
1113 }
1114 self.zobrist ^= zobrist_stone_key(placed, mv);
1116 self.zobrist ^= ZOBRIST_SIDE;
1117
1118 self.history.push(mv);
1119 self.last_move = Some(mv);
1120 self.move_count += 1;
1121 self.side_to_move = placed.opponent();
1122
1123 if let Some(frontier) = candidate_frontier {
1125 self.update_candidate_frontier_after_make(frontier, mv);
1126 }
1127
1128 self.update_line_patterns_around(
1131 mv,
1132 match placed {
1133 Stone::Black => 1,
1134 Stone::White => 2,
1135 },
1136 line_packed_windows,
1137 );
1138 }
1139
1140 pub fn undo_move(&mut self) {
1142 self.undo_move_with_search_state(None, None);
1143 }
1144
1145 #[inline]
1146 fn undo_move_with_search_state(
1147 &mut self,
1148 line_packed_windows: Option<&mut [[u32; 4]; NUM_CELLS]>,
1149 candidate_frontier: Option<&mut CandidateFrontierState>,
1150 ) {
1151 if let Some(mv) = self.history.pop() {
1152 self.side_to_move = self.side_to_move.opponent();
1153 let placed = self.side_to_move;
1154 self.move_count -= 1;
1155 match placed {
1156 Stone::Black => self.black.clear(mv),
1157 Stone::White => self.white.clear(mv),
1158 }
1159 self.zobrist ^= zobrist_stone_key(placed, mv);
1161 self.zobrist ^= ZOBRIST_SIDE;
1162
1163 self.last_move = self.history.last().copied();
1164
1165 if let Some(frontier) = candidate_frontier {
1168 self.update_candidate_frontier_after_undo(frontier, mv);
1169 }
1170
1171 self.update_line_patterns_around(mv, 0, line_packed_windows);
1174 }
1175 }
1176
1177 pub fn line_pattern_dirty_cells(
1181 mv: Move,
1182 out: &mut [usize; LINE_PATTERN_FRONTIER_MAX],
1183 ) -> usize {
1184 let mut seen = [false; NUM_CELLS];
1185 let mut len = 0usize;
1186 Self::for_line_pattern_frontier(mv, |cell, _dir_idx, _offset| {
1187 if !seen[cell] {
1188 seen[cell] = true;
1189 debug_assert!(len < LINE_PATTERN_FRONTIER_MAX);
1190 out[len] = cell;
1191 len += 1;
1192 }
1193 });
1194 len
1195 }
1196
1197 #[inline]
1198 fn for_line_pattern_frontier(mut mv: Move, mut f: impl FnMut(usize, usize, i32)) {
1199 const DIRS: [(i32, i32); 4] = [(0, 1), (1, 0), (1, 1), (1, -1)];
1200 debug_assert!(mv < NUM_CELLS);
1201 let row = (mv / BOARD_SIZE) as i32;
1202 let col = (mv % BOARD_SIZE) as i32;
1203 for (dir_idx, &(dr, dc)) in DIRS.iter().enumerate() {
1204 for offset in -5i32..=5 {
1205 let r = row + dr * offset;
1206 let c = col + dc * offset;
1207 if r < 0 || r >= BOARD_SIZE as i32 || c < 0 || c >= BOARD_SIZE as i32 {
1208 continue;
1209 }
1210 mv = (r as usize) * BOARD_SIZE + c as usize;
1211 f(mv, dir_idx, offset);
1212 }
1213 }
1214 }
1215
1216 #[inline]
1220 fn update_line_patterns_around(
1221 &mut self,
1222 mv: Move,
1223 new_cell: u32,
1224 line_packed_windows: Option<&mut [[u32; 4]; NUM_CELLS]>,
1225 ) {
1226 const DIRS: [(i32, i32); 4] = [(0, 1), (1, 0), (1, 1), (1, -1)];
1227 debug_assert!(new_cell <= 2);
1228 if let Some(windows) = line_packed_windows {
1229 let ids = &mut self.line_pattern_ids;
1230 Self::for_line_pattern_frontier(mv, |cell, dir_idx, offset| {
1231 let shift = ((5 + offset) * 2) as u32;
1235 let mask = 0b11u32 << shift;
1236 let packed = (windows[cell][dir_idx] & !mask) | (new_cell << shift);
1237 windows[cell][dir_idx] = packed;
1238 ids[cell][dir_idx] = crate::pattern_table::lookup_mapped_id(packed);
1239 });
1240 } else {
1241 Self::for_line_pattern_frontier(mv, |cell, dir_idx, _offset| {
1242 let (dr, dc) = DIRS[dir_idx];
1243 let r = (cell / BOARD_SIZE) as i32;
1244 let c = (cell % BOARD_SIZE) as i32;
1245 let window =
1246 crate::pattern_table::read_window(&self.black, &self.white, r, c, dr, dc);
1247 let packed = crate::pattern_table::pack_window(&window);
1248 self.line_pattern_ids[cell][dir_idx] =
1249 crate::pattern_table::lookup_mapped_id(packed);
1250 });
1251 }
1252 }
1253
1254 pub fn check_win(&self, mv: Move) -> bool {
1256 let (row, col) = to_rc(mv);
1257 let (side, stone) = if self.black.get(mv) {
1258 (Stone::Black, &self.black)
1259 } else if self.white.get(mv) {
1260 (Stone::White, &self.white)
1261 } else {
1262 return false;
1263 };
1264 let rules = self.effective_rule_set();
1265
1266 let directions: [(i32, i32); 4] = [(0, 1), (1, 0), (1, 1), (1, -1)];
1267 for &(dr, dc) in &directions {
1268 let (count, open_ends, edge_ends) =
1269 self.line_run_with_edges(stone, row as i32, col as i32, dr, dc);
1270 if rules.line_wins_with_edges(side, count, open_ends, edge_ends) {
1271 return true;
1272 }
1273 }
1274 false
1275 }
1276
1277 #[inline]
1279 pub(crate) fn line_run_with_edges(
1280 &self,
1281 stone: &BitBoard,
1282 row: i32,
1283 col: i32,
1284 dr: i32,
1285 dc: i32,
1286 ) -> (u32, u32, u32) {
1287 let mut count = 1u32;
1288 let mut open_ends = 0u32;
1289 let mut edge_ends = 0u32;
1290
1291 let mut r = row + dr;
1292 let mut c = col + dc;
1293 while in_board(r, c) && stone.get(to_idx(r as usize, c as usize)) {
1294 count += 1;
1295 r += dr;
1296 c += dc;
1297 }
1298 if in_board(r, c) && self.is_empty(to_idx(r as usize, c as usize)) {
1299 open_ends += 1;
1300 }
1301 edge_ends += !in_board(r, c) as u32;
1302
1303 let mut r = row - dr;
1304 let mut c = col - dc;
1305 while in_board(r, c) && stone.get(to_idx(r as usize, c as usize)) {
1306 count += 1;
1307 r -= dr;
1308 c -= dc;
1309 }
1310 if in_board(r, c) && self.is_empty(to_idx(r as usize, c as usize)) {
1311 open_ends += 1;
1312 }
1313 edge_ends += !in_board(r, c) as u32;
1314
1315 (count, open_ends, edge_ends)
1316 }
1317
1318 #[inline]
1319 pub fn is_legal_move(&self, mv: Move) -> bool {
1320 mv < NUM_CELLS && self.is_empty(mv) && !self.is_forbidden_for_side_to_move(mv)
1321 }
1322
1323 #[inline]
1326 pub fn is_forbidden_for_side_to_move(&self, mv: Move) -> bool {
1327 match self.effective_rule_set() {
1328 RuleSet::Renju => self.side_to_move == Stone::Black && crate::renju::is_forbidden(&self.black, &self.white, mv),
1329 RuleSet::Omok => match self.side_to_move {
1330 Stone::Black => crate::renju::is_forbidden_omok(&self.black, &self.white, mv),
1331 Stone::White => crate::renju::is_forbidden_omok(&self.white, &self.black, mv),
1332 },
1333 _ => false,
1334 }
1335 }
1336
1337 #[inline]
1339 pub fn side_to_move_has_forbidden_points(&self) -> bool {
1340 match self.effective_rule_set() {
1341 RuleSet::Renju => self.side_to_move == Stone::Black,
1342 RuleSet::Omok => true,
1343 _ => false,
1344 }
1345 }
1346
1347 pub fn game_result(&self) -> GameResult {
1349 if let Some(mv) = self.last_move {
1350 if self.check_win(mv) {
1351 return match self.side_to_move {
1353 Stone::Black => GameResult::WhiteWin,
1354 Stone::White => GameResult::BlackWin,
1355 };
1356 }
1357 }
1358 if self.move_count >= NUM_CELLS {
1359 GameResult::Draw
1360 } else {
1361 GameResult::Ongoing
1362 }
1363 }
1364}
1365
1366impl fmt::Display for Board {
1367 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1368 write!(f, " ")?;
1369 for c in 0..BOARD_SIZE {
1370 write!(f, "{:2}", (b'A' + c as u8) as char)?;
1371 }
1372 writeln!(f)?;
1373
1374 for r in 0..BOARD_SIZE {
1375 write!(f, "{:2} ", r + 1)?;
1376 for c in 0..BOARD_SIZE {
1377 let idx = to_idx(r, c);
1378 if self.black.get(idx) {
1379 write!(f, " X")?;
1380 } else if self.white.get(idx) {
1381 write!(f, " O")?;
1382 } else {
1383 write!(f, " .")?;
1384 }
1385 }
1386 writeln!(f)?;
1387 }
1388 Ok(())
1389 }
1390}
1391
1392#[cfg(test)]
1393mod tests {
1394 use super::*;
1395
1396 fn put_stone(board: &mut Board, side: Stone, row: usize, col: usize) -> Move {
1397 let mv = to_idx(row, col);
1398 assert!(board.is_empty(mv));
1399 match side {
1400 Stone::Black => board.black.set(mv),
1401 Stone::White => board.white.set(mv),
1402 }
1403 board.move_count += 1;
1404 mv
1405 }
1406
1407 #[test]
1408 fn test_make_undo_move() {
1409 let mut board = Board::new();
1410 let mv = to_idx(7, 7);
1411 board.make_move(mv);
1412 assert!(board.black.get(mv));
1413 assert_eq!(board.side_to_move, Stone::White);
1414
1415 board.undo_move();
1416 assert!(!board.black.get(mv));
1417 assert_eq!(board.side_to_move, Stone::Black);
1418 assert_eq!(board.move_count, 0);
1419 }
1420
1421 #[test]
1423 fn zobrist_make_undo_roundtrip() {
1424 let mut board = Board::new();
1425 let initial = board.zobrist;
1426 assert_eq!(initial, 0, "empty board zobrist should be 0");
1427
1428 let moves = [112, 113, 97, 98, 127, 128, 200, 14];
1429 let mut keys = vec![initial];
1430 for &m in &moves {
1431 board.make_move(m);
1432 keys.push(board.zobrist);
1433 }
1434 let mut sorted = keys.clone();
1436 sorted.sort_unstable();
1437 sorted.dedup();
1438 assert_eq!(sorted.len(), keys.len(), "zobrist sequence collided");
1439
1440 for i in (1..keys.len()).rev() {
1442 board.undo_move();
1443 assert_eq!(
1444 board.zobrist,
1445 keys[i - 1],
1446 "zobrist mismatch after undo step {i}"
1447 );
1448 }
1449 assert_eq!(
1450 board.zobrist, 0,
1451 "zobrist did not return to 0 after full undo"
1452 );
1453 }
1454
1455 #[test]
1459 fn line_pattern_state_make_undo_consistency() {
1460 const DIRS: [(i32, i32); 4] = [(0, 1), (1, 0), (1, 1), (1, -1)];
1461 let moves = [112, 113, 97, 98, 127, 128, 200, 14, 0, 224, 7, 217, 50, 100];
1462
1463 let mut board = Board::new();
1464 let initial_ids = board.line_pattern_ids.clone();
1465
1466 for (i, &mv) in moves.iter().enumerate() {
1468 if !board.is_empty(mv) {
1469 continue;
1470 }
1471 board.make_move(mv);
1472
1473 let mut fresh = Board::new();
1475 for &m in &moves[..=i] {
1476 if fresh.is_empty(m) {
1477 fresh.make_move(m);
1478 }
1479 }
1480 for cell in 0..NUM_CELLS {
1484 let row = (cell / BOARD_SIZE) as i32;
1485 let col = (cell % BOARD_SIZE) as i32;
1486 for (dir_idx, &(dr, dc)) in DIRS.iter().enumerate() {
1487 let w = crate::pattern_table::read_window(
1488 &board.black,
1489 &board.white,
1490 row,
1491 col,
1492 dr,
1493 dc,
1494 );
1495 let packed = crate::pattern_table::pack_window(&w);
1496 let expected = crate::pattern_table::lookup_mapped_id(packed);
1497 let actual = board.line_pattern_ids[cell][dir_idx];
1498 assert_eq!(
1499 actual,
1500 expected,
1501 "mismatch at cell {} dir {} after move {} (ply {})",
1502 cell,
1503 dir_idx,
1504 mv,
1505 i + 1
1506 );
1507 }
1508 }
1509 }
1510
1511 for _ in 0..moves.len() {
1513 if !board.history.is_empty() {
1514 board.undo_move();
1515 }
1516 }
1517 assert_eq!(board.move_count, 0);
1518 for cell in 0..NUM_CELLS {
1520 for d in 0..4 {
1521 assert_eq!(
1522 board.line_pattern_ids[cell][d], initial_ids[cell][d],
1523 "after full undo: cell {} dir {} not restored",
1524 cell, d
1525 );
1526 }
1527 }
1528 }
1529
1530 fn assert_packed_windows_match_full_rebuild(
1531 board: &Board,
1532 state: &BoardSearchState,
1533 operation: usize,
1534 ) {
1535 const DIRS: [(i32, i32); 4] = [(0, 1), (1, 0), (1, 1), (1, -1)];
1536 assert!(state.packed_line_windows_enabled());
1537 assert!(state.is_synchronized(board));
1538 for cell in 0..NUM_CELLS {
1539 let row = (cell / BOARD_SIZE) as i32;
1540 let col = (cell % BOARD_SIZE) as i32;
1541 for (dir_idx, &(dr, dc)) in DIRS.iter().enumerate() {
1542 let window =
1543 crate::pattern_table::read_window(&board.black, &board.white, row, col, dr, dc);
1544 let expected_packed = crate::pattern_table::pack_window(&window);
1545 let actual_packed = state.packed_line_window(cell, dir_idx).unwrap();
1546 assert_eq!(
1547 actual_packed, expected_packed,
1548 "packed mismatch after operation {operation}, cell {cell}, dir {dir_idx}"
1549 );
1550 assert_eq!(
1551 board.line_pattern_ids[cell][dir_idx],
1552 crate::pattern_table::lookup_mapped_id(expected_packed),
1553 "mapped-id mismatch after operation {operation}, cell {cell}, dir {dir_idx}"
1554 );
1555 }
1556 }
1557 }
1558
1559 #[test]
1560 fn packed_line_windows_are_opt_in_for_library_callers_and_toggle_cleanly() {
1561 let mut board = Board::new();
1562 let mut state = BoardSearchState::new();
1563 assert!(!state.packed_line_windows_enabled());
1564 board.make_move(to_idx(7, 7));
1565 state.set_packed_line_windows_enabled(&board, true);
1566 assert_packed_windows_match_full_rebuild(&board, &state, 1);
1567 state.set_packed_line_windows_enabled(&board, false);
1568 assert!(!state.packed_line_windows_enabled());
1569 board.undo_move();
1570 let empty = Board::new();
1571 assert!(board.black == empty.black);
1572 assert!(board.white == empty.white);
1573 assert_eq!(board.side_to_move, empty.side_to_move);
1574 assert_eq!(board.move_count, 0);
1575 assert_eq!(board.line_pattern_ids, empty.line_pattern_ids);
1576 }
1577
1578 #[test]
1579 fn d4_hash_composes_with_existing_sidecars_and_make_undo() {
1580 let sequence = [
1581 to_idx(7, 7),
1582 to_idx(0, 0),
1583 to_idx(14, 14),
1584 to_idx(6, 8),
1585 to_idx(2, 12),
1586 to_idx(11, 3),
1587 ];
1588 let mut board = Board::new();
1589 let mut state = BoardSearchState::new();
1590 state.set_packed_line_windows_enabled(&board, true);
1591 state.set_candidate_frontier_enabled(&board, true);
1592 state.set_d4_hash_enabled(&board, true);
1593
1594 assert!(state.packed_line_windows_enabled());
1595 assert!(state.candidate_frontier_enabled());
1596 assert!(state.d4_hash_enabled());
1597 let root_hashes = state.d4_hashes(&board).expect("D4 hash enabled");
1598 assert_eq!(
1599 root_hashes,
1600 *crate::d4_hash::D4HashState::rebuild(&board).hashes()
1601 );
1602
1603 for &mv in &sequence {
1604 let predicted_hashes = state
1605 .d4_predicted_child_hashes(&board, mv)
1606 .expect("legal child prediction");
1607 let predicted_context = state
1608 .d4_predicted_child_context(&board, mv)
1609 .expect("legal child prediction");
1610 state.make_move(&mut board, mv);
1611 assert_eq!(state.d4_hashes(&board), Some(predicted_hashes));
1612 assert_eq!(state.d4_canonical_context(&board), Some(predicted_context));
1613 assert_eq!(
1614 state.d4_hashes(&board),
1615 Some(*crate::d4_hash::D4HashState::rebuild(&board).hashes())
1616 );
1617 assert_packed_windows_match_full_rebuild(&board, &state, board.move_count);
1618 assert_candidate_frontier_matches_full_rebuild(&board, &state, board.move_count);
1619 }
1620
1621 for operation in (0..sequence.len()).rev() {
1622 state.undo_move(&mut board);
1623 assert_eq!(
1624 state.d4_hashes(&board),
1625 Some(*crate::d4_hash::D4HashState::rebuild(&board).hashes()),
1626 "D4 hashes after undo {operation}"
1627 );
1628 }
1629 assert_eq!(state.d4_hashes(&board), Some(root_hashes));
1630 state.undo_move(&mut board);
1631 assert_eq!(
1632 state.d4_hashes(&board),
1633 Some(root_hashes),
1634 "empty-history undo must not toggle D4 hashes"
1635 );
1636 assert!(state.packed_line_windows_enabled());
1637 assert!(state.candidate_frontier_enabled());
1638 assert!(state.d4_hash_enabled());
1639 }
1640
1641 #[test]
1642 fn d4_hash_rule_change_forces_resync_and_preserves_all_enable_flags() {
1643 let mut board = Board::new();
1644 let mut state = BoardSearchState::new();
1645 state.set_packed_line_windows_enabled(&board, true);
1646 state.set_candidate_frontier_enabled(&board, true);
1647 state.set_d4_hash_enabled(&board, true);
1648 let freestyle = state.d4_hashes(&board).expect("D4 hash enabled");
1649
1650 board.set_rule_set(RuleSet::Caro);
1651 assert!(!state.is_synchronized(&board));
1652 assert_eq!(state.d4_hashes(&board), None);
1653 assert_eq!(state.d4_canonical_context(&board), None);
1654 state.synchronize(&board);
1655 let caro = state.d4_hashes(&board).expect("D4 hash rebuilt");
1656 assert_ne!(caro, freestyle);
1657 assert_eq!(caro, *crate::d4_hash::D4HashState::rebuild(&board).hashes());
1658 assert!(state.packed_line_windows_enabled());
1659 assert!(state.candidate_frontier_enabled());
1660 assert!(state.d4_hash_enabled());
1661
1662 board.set_rule_set(RuleSet::Freestyle);
1666 state.synchronize(&board);
1667 let freestyle_again = state.d4_hashes(&board).unwrap();
1668 board.exact5 = true;
1669 assert_eq!(board.effective_rule_set(), RuleSet::Standard);
1670 assert!(!state.is_synchronized(&board));
1671 state.synchronize(&board);
1672 let standard = state.d4_hashes(&board).unwrap();
1673 assert_ne!(standard, freestyle_again);
1674 assert!(state.packed_line_windows_enabled());
1675 assert!(state.candidate_frontier_enabled());
1676 assert!(state.d4_hash_enabled());
1677 }
1678
1679 #[test]
1683 #[ignore = "100k release audit; run explicitly with --release --ignored"]
1684 fn packed_line_windows_100k_make_undo_full_rebuild_equality() {
1685 const OPERATIONS: usize = 100_000;
1686 const FULL_REBUILD_PERIOD: usize = 97;
1687
1688 let mut legacy = Board::new();
1689 let mut packed = Board::new();
1690 let mut state = BoardSearchState::new();
1691 state.set_packed_line_windows_enabled(&packed, true);
1692 let mut rng = 0xDFA2_2026_0725_0001u64;
1693
1694 for operation in 1..=OPERATIONS {
1695 rng ^= rng << 13;
1696 rng ^= rng >> 7;
1697 rng ^= rng << 17;
1698 let should_undo =
1699 !legacy.history.is_empty() && (legacy.move_count >= 180 || (rng & 0b11) == 0);
1700
1701 if should_undo {
1702 legacy.undo_move();
1703 state.undo_move(&mut packed);
1704 } else {
1705 let start = (rng as usize) % NUM_CELLS;
1706 let mv = (0..NUM_CELLS)
1707 .map(|delta| (start + delta) % NUM_CELLS)
1708 .find(|&cell| legacy.is_empty(cell))
1709 .expect("at least one empty cell before make");
1710 legacy.make_move(mv);
1711 state.make_move(&mut packed, mv);
1712 }
1713
1714 assert!(
1715 packed.black == legacy.black,
1716 "black at operation {operation}"
1717 );
1718 assert!(
1719 packed.white == legacy.white,
1720 "white at operation {operation}"
1721 );
1722 assert_eq!(
1723 packed.side_to_move, legacy.side_to_move,
1724 "side at operation {operation}"
1725 );
1726 assert_eq!(
1727 packed.line_pattern_ids, legacy.line_pattern_ids,
1728 "mapped IDs at operation {operation}"
1729 );
1730
1731 if operation % FULL_REBUILD_PERIOD == 0 || operation == OPERATIONS {
1732 assert_packed_windows_match_full_rebuild(&packed, &state, operation);
1733 }
1734 }
1735
1736 while !legacy.history.is_empty() {
1737 legacy.undo_move();
1738 state.undo_move(&mut packed);
1739 }
1740 assert_eq!(packed.line_pattern_ids, legacy.line_pattern_ids);
1741 assert_packed_windows_match_full_rebuild(&packed, &state, OPERATIONS + 1);
1742 }
1743
1744 #[test]
1747 fn zobrist_path_independence() {
1748 let seq1 = [112, 113, 97, 98]; let _seq2 = [112, 98, 97, 113]; let seq2 = [97, 113, 112, 98];
1756
1757 let mut b1 = Board::new();
1758 for &m in &seq1 {
1759 b1.make_move(m);
1760 }
1761 let mut b2 = Board::new();
1762 for &m in &seq2 {
1763 b2.make_move(m);
1764 }
1765
1766 assert_eq!(b1.black.words(), b2.black.words());
1767 assert_eq!(b1.white.words(), b2.white.words());
1768 assert_eq!(b1.side_to_move, b2.side_to_move);
1769
1770 assert_eq!(
1771 b1.zobrist, b2.zobrist,
1772 "same position should have same zobrist"
1773 );
1774 }
1775
1776 #[test]
1777 fn test_horizontal_win() {
1778 let mut board = Board::new();
1779 for i in 0..5 {
1782 board.make_move(to_idx(7, 3 + i)); if i < 4 {
1784 board.make_move(to_idx(8, 3 + i)); }
1786 }
1787 assert_eq!(board.game_result(), GameResult::BlackWin);
1788 }
1789
1790 #[test]
1791 fn test_diagonal_win() {
1792 let mut board = Board::new();
1793 for i in 0..5 {
1796 board.make_move(to_idx(i, i)); if i < 4 {
1798 board.make_move(to_idx(i, i + 1)); }
1800 }
1801 assert_eq!(board.game_result(), GameResult::BlackWin);
1802 }
1803
1804 #[test]
1805 fn test_no_win_with_four() {
1806 let mut board = Board::new();
1807 for i in 0..4 {
1808 board.make_move(to_idx(7, 3 + i)); board.make_move(to_idx(8, 3 + i)); }
1811 assert_eq!(board.game_result(), GameResult::Ongoing);
1812 }
1813
1814 #[test]
1815 fn freestyle_overline_wins() {
1816 let mut board = Board::new();
1817 let mut last = 0;
1818 for col in 3..=8 {
1819 last = put_stone(&mut board, Stone::Black, 7, col);
1820 }
1821 assert!(board.check_win(last));
1822 }
1823
1824 #[test]
1825 fn omok_overline_does_not_win_for_either_side() {
1826 for side in [Stone::Black, Stone::White] {
1827 let mut board = Board::new();
1828 board.set_rule_set(RuleSet::Omok);
1829 let mut last = 0;
1830 for col in 3..=8 {
1831 last = put_stone(&mut board, side, 7, col);
1832 }
1833 assert!(!board.check_win(last), "{side:?} overline");
1834 let mut board = Board::new();
1835 board.set_rule_set(RuleSet::Omok);
1836 for col in 3..=7 {
1837 last = put_stone(&mut board, side, 7, col);
1838 }
1839 assert!(board.check_win(last), "{side:?} exact five");
1840 }
1841 }
1842
1843 #[test]
1844 fn omok_forbids_double_three_for_both_sides_only() {
1845 let mut board = Board::new();
1847 board.set_rule_set(RuleSet::Omok);
1848 for (r, c) in [(7, 5), (7, 6), (5, 7), (6, 7)] {
1849 put_stone(&mut board, Stone::Black, r, c);
1850 }
1851 for (r, c) in [(3, 1), (3, 2), (1, 3), (2, 3)] {
1852 put_stone(&mut board, Stone::White, r, c);
1853 }
1854 board.side_to_move = Stone::Black;
1855 assert!(!board.is_legal_move(to_idx(7, 7)));
1856 assert!(board.is_legal_move(to_idx(3, 3)));
1857 board.side_to_move = Stone::White;
1858 assert!(!board.is_legal_move(to_idx(3, 3)));
1859 assert!(board.is_legal_move(to_idx(7, 7)));
1860 board.set_rule_set(RuleSet::Renju);
1862 assert!(board.is_legal_move(to_idx(3, 3)));
1863 board.side_to_move = Stone::Black;
1864 assert!(!board.is_legal_move(to_idx(7, 7)));
1865 board.set_rule_set(RuleSet::Freestyle);
1867 assert!(board.is_legal_move(to_idx(7, 7)));
1868 }
1869
1870 #[test]
1871 fn omok_allows_double_four() {
1872 let mut board = Board::new();
1873 board.set_rule_set(RuleSet::Omok);
1874 for (r, c) in [(7, 4), (7, 5), (7, 6), (4, 7), (5, 7), (6, 7)] {
1875 put_stone(&mut board, Stone::White, r, c);
1876 }
1877 board.side_to_move = Stone::White;
1878 assert!(board.is_legal_move(to_idx(7, 7)));
1879 }
1880
1881 #[test]
1882 fn standard_overline_does_not_win() {
1883 let mut board = Board::new();
1884 board.set_rule_set(RuleSet::Standard);
1885 let mut last = 0;
1886 for col in 3..=8 {
1887 last = put_stone(&mut board, Stone::Black, 7, col);
1888 }
1889 assert!(!board.check_win(last));
1890 }
1891
1892 #[test]
1893 fn standard_exact_five_wins() {
1894 let mut board = Board::new();
1895 board.set_rule_set(RuleSet::Standard);
1896 let mut last = 0;
1897 for col in 3..=7 {
1898 last = put_stone(&mut board, Stone::Black, 7, col);
1899 }
1900 assert!(board.check_win(last));
1901 }
1902
1903 #[test]
1904 fn caro_blocked_exact_five_does_not_win() {
1905 let mut board = Board::new();
1906 board.set_rule_set(RuleSet::Caro);
1907 put_stone(&mut board, Stone::White, 7, 3);
1908 put_stone(&mut board, Stone::White, 7, 9);
1909 let mut last = 0;
1910 for col in 4..=8 {
1911 last = put_stone(&mut board, Stone::Black, 7, col);
1912 }
1913 assert!(!board.check_win(last));
1914 }
1915
1916 #[test]
1917 fn caro_one_open_exact_five_wins() {
1918 let mut board = Board::new();
1919 board.set_rule_set(RuleSet::Caro);
1920 put_stone(&mut board, Stone::White, 7, 3);
1921 let mut last = 0;
1922 for col in 4..=8 {
1923 last = put_stone(&mut board, Stone::Black, 7, col);
1924 }
1925 assert!(board.check_win(last));
1926 }
1927
1928 #[test]
1931 fn caro_overline_does_not_win() {
1932 let mut board = Board::new();
1933 board.set_rule_set(RuleSet::Caro);
1934 let mut last = 0;
1935 for col in 4..=9 {
1936 last = put_stone(&mut board, Stone::Black, 7, col);
1937 }
1938 assert!(!board.check_win(last));
1939 }
1940
1941 #[test]
1942 fn caro_edge_does_not_block_exact_five() {
1943 let mut board = Board::new();
1944 board.set_rule_set(RuleSet::Caro);
1945 put_stone(&mut board, Stone::White, 7, 5);
1946 let mut last = 0;
1947 for col in 0..=4 {
1948 last = put_stone(&mut board, Stone::Black, 7, col);
1949 }
1950 assert!(board.check_win(last));
1951 }
1952
1953 #[test]
1954 fn caro_judge_probe_blocked_five_is_not_a_threat() {
1955 let mut board = Board::new();
1959 board.set_rule_set(RuleSet::Caro);
1960 put_stone(&mut board, Stone::White, 7, 5);
1961 put_stone(&mut board, Stone::White, 7, 11);
1962 let mut last = 0;
1963 for col in 6..=10 {
1964 last = put_stone(&mut board, Stone::Black, 7, col);
1965 }
1966 assert!(!board.check_win(last));
1967 }
1968
1969 #[test]
1970 fn test_candidate_moves_first() {
1971 let board = Board::new();
1972 let moves = board.candidate_moves();
1973 assert_eq!(moves, vec![to_idx(BOARD_SIZE / 2, BOARD_SIZE / 2)]);
1974 }
1975
1976 #[test]
1977 fn candidate_masks_preserve_legacy_order_through_make_undo() {
1978 let mut rng = 9168200u64;
1979 for _ in 0..400 {
1980 let mut cells: Vec<_> = (0..NUM_CELLS).collect();
1981 for i in (1..NUM_CELLS).rev() {
1982 rng ^= rng << 13; rng ^= rng >> 7; rng ^= rng << 17;
1983 cells.swap(i, rng as usize % (i + 1));
1984 }
1985 let mut board = Board::new();
1986 for cell in cells {
1987 board.make_move(cell);
1988 assert_eq!(board.candidate_moves(), board.candidate_moves_legacy());
1989 }
1990 for _ in 0..NUM_CELLS {
1991 board.undo_move();
1992 let expected = if board.move_count == 0 { vec![to_idx(BOARD_SIZE / 2, BOARD_SIZE / 2)] }
1993 else { board.candidate_moves_legacy() };
1994 assert_eq!(board.candidate_moves(), expected);
1995 }
1996 }
1997 }
1998
1999 fn assert_candidate_frontier_matches_full_rebuild(
2000 board: &Board,
2001 state: &BoardSearchState,
2002 operation: usize,
2003 ) {
2004 assert!(state.is_synchronized(board));
2005 let actual = state
2006 .candidate_frontier
2007 .as_ref()
2008 .expect("candidate frontier must be enabled");
2009 let rebuilt = board.rebuild_candidate_frontier();
2010 assert_eq!(
2011 actual.radius2_count, rebuilt.radius2_count,
2012 "radius2 counts at operation {operation}"
2013 );
2014 assert_eq!(
2015 actual.min_source, rebuilt.min_source,
2016 "minimum sources at operation {operation}"
2017 );
2018 assert!(
2019 actual.candidates == rebuilt.candidates,
2020 "candidate bitboard at operation {operation}"
2021 );
2022 assert!(
2023 actual.nonempty_sources == rebuilt.nonempty_sources,
2024 "non-empty source buckets at operation {operation}"
2025 );
2026 for source in 0..NUM_CELLS {
2027 assert!(
2028 actual.by_min_source[source] == rebuilt.by_min_source[source],
2029 "source bucket {source} at operation {operation}"
2030 );
2031 }
2032 assert_eq!(
2033 state.candidate_moves(board),
2034 if board.move_count == 0 {
2035 vec![to_idx(7, 7)]
2036 } else {
2037 board.candidate_moves_legacy()
2038 },
2039 "exact candidate order at operation {operation}"
2040 );
2041 }
2042
2043 #[test]
2044 fn candidate_frontier_is_opt_in_and_preserves_exact_order() {
2045 let sequence = [
2046 to_idx(7, 7),
2047 to_idx(0, 0),
2048 to_idx(14, 14),
2049 to_idx(7, 8),
2050 to_idx(2, 13),
2051 to_idx(12, 1),
2052 ];
2053 let mut board = Board::new();
2054 let mut state = BoardSearchState::new();
2055 assert!(!state.candidate_frontier_enabled());
2056 for &mv in &sequence {
2057 board.make_move(mv);
2058 }
2059 let expected = board.candidate_moves();
2060 state.set_candidate_frontier_enabled(&board, true);
2061 assert!(state.candidate_frontier_enabled());
2062 assert_eq!(state.candidate_moves(&board), expected);
2063 assert_candidate_frontier_matches_full_rebuild(&board, &state, sequence.len());
2064 state.set_candidate_frontier_enabled(&board, false);
2065 assert!(!state.candidate_frontier_enabled());
2066 assert_eq!(state.candidate_moves(&board), expected);
2067 }
2068
2069 #[test]
2073 #[ignore = "100k release audit; run explicitly with --release --ignored"]
2074 fn candidate_frontier_100k_make_undo_full_rebuild_equality() {
2075 const OPERATIONS: usize = 100_000;
2076 const FULL_REBUILD_PERIOD: usize = 97;
2077
2078 let mut legacy = Board::new();
2079 let mut incremental = Board::new();
2080 let mut state = BoardSearchState::new();
2081 state.set_packed_line_windows_enabled(&incremental, true);
2082 state.set_candidate_frontier_enabled(&incremental, true);
2083 let mut rng = 0xDFA3_2026_0725_0001u64;
2084
2085 for operation in 1..=OPERATIONS {
2086 rng ^= rng << 13;
2087 rng ^= rng >> 7;
2088 rng ^= rng << 17;
2089 let should_undo =
2090 !legacy.history.is_empty() && (legacy.move_count >= 180 || (rng & 0b11) == 0);
2091
2092 if should_undo {
2093 legacy.undo_move();
2094 state.undo_move(&mut incremental);
2095 } else {
2096 let start = (rng as usize) % NUM_CELLS;
2097 let mv = (0..NUM_CELLS)
2098 .map(|delta| (start + delta) % NUM_CELLS)
2099 .find(|&cell| legacy.is_empty(cell))
2100 .expect("at least one empty cell before make");
2101 legacy.make_move(mv);
2102 state.make_move(&mut incremental, mv);
2103 }
2104
2105 assert!(incremental.black == legacy.black);
2106 assert!(incremental.white == legacy.white);
2107 assert_eq!(incremental.side_to_move, legacy.side_to_move);
2108 assert_eq!(
2109 incremental.line_pattern_ids, legacy.line_pattern_ids,
2110 "mapped pattern IDs at operation {operation}"
2111 );
2112 assert_eq!(
2113 state.candidate_moves(&incremental),
2114 legacy.candidate_moves(),
2115 "ordered candidates at operation {operation}"
2116 );
2117
2118 if operation % FULL_REBUILD_PERIOD == 0 || operation == OPERATIONS {
2119 assert_packed_windows_match_full_rebuild(&incremental, &state, operation);
2120 assert_candidate_frontier_matches_full_rebuild(&incremental, &state, operation);
2121 }
2122 }
2123
2124 while !legacy.history.is_empty() {
2125 legacy.undo_move();
2126 state.undo_move(&mut incremental);
2127 assert_eq!(
2128 state.candidate_moves(&incremental),
2129 legacy.candidate_moves()
2130 );
2131 }
2132 assert_packed_windows_match_full_rebuild(&incremental, &state, OPERATIONS + 1);
2133 assert_candidate_frontier_matches_full_rebuild(&incremental, &state, OPERATIONS + 1);
2134 }
2135}