Skip to main content

figrid_board/
board.rs

1/// 오목 보드 엔진
2///
3/// 15×15 보드. Bitboard 표현 (u128 × 2로 225비트 커버).
4/// 흑(선공)과 백(후공) 각각 bitboard 보유.
5use std::fmt;
6
7/// Board side: 15 by default; the `board20` feature builds the 20x20 engine (Gomocup Freestyle / Fastgame).
8#[cfg(not(feature = "board20"))]
9pub const BOARD_SIZE: usize = 15;
10#[cfg(feature = "board20")]
11pub const BOARD_SIZE: usize = 20;
12/// Smallest unsigned type that holds a cell index (`u8` for 15x15, `u16` for 20x20).
13#[cfg(not(feature = "board20"))]
14pub type CellIdx = u8;
15#[cfg(feature = "board20")]
16pub type CellIdx = u16;
17/// 128-bit words in a [`BitBoard`].
18pub const BITBOARD_WORDS: usize = NUM_CELLS.div_ceil(128);
19pub const NUM_CELLS: usize = BOARD_SIZE * BOARD_SIZE; // 225
20pub 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    /// Black wins with exactly five and may not play forbidden points (double-four, double-three, overline;
43    /// see [`crate::renju`]); white wins with five or more.
44    Renju,
45    /// Korean omok rule: both sides win only with exactly five and neither may play a double-three
46    /// ([`crate::renju::is_forbidden_omok`]); double-fours and overlines are legal. Not a Gomocup rule; the pbrain
47    /// protocol uses `INFO rule 17` (16 | exact five) for it.
48    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    /// Win test when only the empty terminals are known. For Caro this treats
58    /// a board edge as blocking, which is stricter than Gomocup; prefer
59    /// [`RuleSet::line_wins_with_edges`] whenever the edge count is available.
60    #[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    /// Win test with the GomocupJudge terminal semantics: `open_ends` counts
66    /// empty in-board terminals and `edge_ends` counts terminals past the
67    /// board. Caro (Gomocup `rule 9`) wins only with exactly five that is not
68    /// blocked at both ends by opponent stones; a board edge does not block.
69    #[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/// 225비트를 u128 × 2로 표현
90/// lo: 비트 0~127, hi: 비트 128~224
91#[cfg(not(feature = "board20"))]
92#[derive(Clone, Copy, PartialEq, Eq)]
93pub struct BitBoard {
94    pub lo: u128,
95    pub hi: u128,
96}
97
98/// 400 cells in four 128-bit words (`board20`), bit `i` in word `i / 128`.
99#[cfg(feature = "board20")]
100#[derive(Clone, Copy, PartialEq, Eq)]
101pub struct BitBoard {
102    pub w: [u128; BITBOARD_WORDS],
103}
104
105// Clipped radius-two neighborhoods. Enumerating stones and each newly seen
106// neighborhood in ascending order preserves the legacy candidate tie-breaking.
107const 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    /// Words in ascending bit order (`[lo, hi]`).
134    #[inline]
135    pub const fn words(&self) -> [u128; BITBOARD_WORDS] {
136        [self.lo, self.hi]
137    }
138
139    /// Copy with bit `idx` set (usable in const tables).
140    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    /// Iterate over the indices of set bits, lowest first.
209    /// Enables feature extraction loops to skip empty cells entirely —
210    /// critical when the board is sparse (early/midgame), since a
211    /// stone-driven pass is ~6× cheaper than scanning all 225 cells.
212    #[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    /// Words in ascending bit order.
226    #[inline]
227    pub const fn words(&self) -> [u128; BITBOARD_WORDS] {
228        self.w
229    }
230
231    /// Copy with bit `idx` set (usable in const tables).
232    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    /// Iterate over the indices of set bits, lowest first.
284    #[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
347/// 착수 = 보드 인덱스 (0~224)
348pub 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
365/// Zobrist 키 — 보드 상태의 고유 해시.
366/// `(cell, color)` 별로 고정 random u64를 XOR 해서 만든다.
367/// `side_to_move` 도 별도 키로 toggle. make/undo 시 incremental XOR 갱신.
368mod zobrist {
369    use super::{NUM_CELLS, Stone};
370
371    /// 결정적이지만 잘 분산된 splitmix64 변형으로 컴파일 타임 키 생성.
372    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/// Domain-separation key for the effective game rule in the D4 hash sidecar.
416///
417/// These seeds are frozen by the CB-GH0 preregistration. Keep this function
418/// tied to the board's existing SplitMix64 implementation so the construction
419/// cannot silently drift between the ordinary and D4 Zobrist families.
420#[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
432/// 4 directional 11-cell line pattern mapped IDs per cell.
433/// Pattern4 mini의 incremental state cache. 값 ∈ [0, PATTERN_NUM_IDS)
434/// (= 0..PATTERN_NUM_IDS): swap-closed mapped ids plus rare bucket. u16에 들어감.
435///
436/// Black-relative storage: 1=black, 2=white로 read_window. side_to_move
437/// 변경에 따라 ID 재계산 안 함 (perspective 변환은 NNUE feature 매핑
438/// 단계에서 처리).
439pub type LinePatternState = Box<[[u16; 4]; NUM_CELLS]>;
440
441const NO_CANDIDATE_SOURCE: CellIdx = CellIdx::MAX;
442
443/// Optional radius-2 candidate frontier.
444///
445/// `by_min_source[s]` contains exactly the empty candidates whose lowest
446/// occupied radius-2 neighbor is `s`. Iterating non-empty buckets and then
447/// their bits in ascending order reproduces the legacy discovery order.
448#[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
518/// Incrementally maintained raw 22-bit Pattern4 windows.
519///
520/// Entries use the same black-relative layout as `pack_window`: window
521/// index 0 occupies bits 21..20 and index 10 occupies bits 1..0.
522pub type LinePackedWindowState = Box<[[u32; 4]; NUM_CELLS]>;
523
524/// Search-only acceleration state kept outside [`Board`].
525///
526/// This sidecar deliberately preserves the exact public `Board` field shape
527/// from figrid-board 0.8.1, so downstream exhaustive struct literals remain
528/// source-compatible. Callers that opt in must route every searched
529/// make/undo through this state while it is enabled.
530#[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    /// Whether every enabled cache describes the supplied board position.
545    #[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    /// Rebuild all currently enabled caches if the board changed outside the
559    /// sidecar (for example, between two searches).
560    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    /// Enable or disable incremental packed Pattern4 windows.
591    ///
592    /// Enabling performs one full rebuild at the supplied root. Descendant
593    /// make/undo operations routed through this sidecar then update only one
594    /// 2-bit slot per affected window.
595    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    /// Expose one packed value for correctness and audit harnesses.
636    #[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    /// Enable or disable the exact-order incremental candidate frontier.
644    ///
645    /// Enabling performs one full rebuild at the supplied root.
646    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    /// Enable or disable the default-off incremental D4 hash sidecar.
664    ///
665    /// Enabling rebuilds all eight transformed hashes from the supplied root.
666    /// The ordinary public [`Board`] layout remains unchanged.
667    #[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    /// Return a copy of the eight transformed hashes only when this sidecar
687    /// is enabled and synchronized to `board`.
688    #[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    /// Return the current canonical hash context only for synchronized state.
698    #[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    /// Predict all child hashes without mutating either the board or sidecar.
710    #[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    /// Predict the child's canonical hash context without mutation.
720    #[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    /// Generate candidates in exactly the legacy discovery order.
734    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    /// Apply a move while maintaining every enabled sidecar cache.
757    #[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    /// Undo a move while maintaining every enabled sidecar cache.
782    #[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    /// 착수 이력 (undo를 위해)
818    pub history: Vec<Move>,
819    /// Zobrist 해시. make_move/undo_move에서 XOR로 incremental 갱신.
820    /// 보드 상태(돌 배치 + side_to_move)의 64-bit fingerprint —
821    /// transposition table 키로 사용.
822    pub zobrist: u64,
823    /// Pattern4 mini state cache. 각 (cell, dir) 11-cell 윈도우의
824    /// canonical pattern ID (black-relative). 빈 보드는 모두 ID 0
825    /// (empty_pattern_id). make/undo가 영향받는 cell의 ID만 lookup으로
826    /// 재계산해 region recompute를 피함. NNUE feature 매핑은 Phase 3에서.
827    pub line_pattern_ids: LinePatternState,
828    /// Active Gomoku rule set for terminal line checks.
829    pub rule_set: RuleSet,
830    /// Backward-compatible Standard-rule flag used by older tools.
831    /// Prefer `set_rule_set` for new code.
832    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            // 빈 보드 + Black to move 의 zobrist 는 0.
845            zobrist: 0,
846            // 정확한 초기값은 fill_initial_pattern_ids 에서 채움 (가장자리는
847            // boundary 포함이라 ID ≠ 0).
848            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        // Backward compatibility for older tools that still do
865        // `board.exact5 = true` after Board::new().
866        if self.exact5 && matches!(self.rule_set, RuleSet::Freestyle) {
867            RuleSet::Standard
868        } else {
869            self.rule_set
870        }
871    }
872
873    /// 빈 보드 기준 모든 (cell, dir) line pattern mapped ID를 lookup해 채움.
874    /// new() 에서만 호출. 가장자리 cell은 boundary 포함 패턴이라 빈 cell의
875    /// 안쪽 ID(보통 0)과 다른 mapped ID로 채워짐.
876    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    /// 해당 칸이 비어있는지
892    #[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    /// 현재 턴의 돌 bitboard
899    #[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    /// 상대 턴의 돌 bitboard
908    #[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    /// 합법 수 목록 생성
917    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    /// 빈 칸 주변(2칸 이내)만 후보로 생성 — 탐색 효율화
1008    pub fn candidate_moves(&self) -> Vec<Move> {
1009        if self.move_count == 0 {
1010            // 첫 수: 천원
1011            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    /// 착수
1095    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        // Zobrist incremental: 새 돌의 (color, cell) 키 XOR + side toggle.
1115        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        // Maintain the optional radius-2 frontier after the stone is visible.
1124        if let Some(frontier) = candidate_frontier {
1125            self.update_candidate_frontier_after_make(frontier, mv);
1126        }
1127
1128        // Pattern4 mini state cache: mv 주변 4방향 ±5 cell의 pattern_id 갱신.
1129        // black-relative: read_window의 첫 인자 = black. side_to_move 무관.
1130        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    /// 착수 취소
1141    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            // Zobrist는 XOR이라 같은 키 한 번 더 적용 = 원복.
1160            self.zobrist ^= zobrist_stone_key(placed, mv);
1161            self.zobrist ^= ZOBRIST_SIDE;
1162
1163            self.last_move = self.history.last().copied();
1164
1165            // The stone is already cleared, so restore the inverse radius-2
1166            // delta and reinsert `mv` when another stone reaches it.
1167            if let Some(frontier) = candidate_frontier {
1168                self.update_candidate_frontier_after_undo(frontier, mv);
1169            }
1170
1171            // Pattern4 state cache: mv 주변 4방향 ±5 cell 다시 read+lookup.
1172            // mv는 이미 cleared된 상태라 새 윈도우에서 mv = empty.
1173            self.update_line_patterns_around(mv, 0, line_packed_windows);
1174        }
1175    }
1176
1177    /// Unique cells touched by the same 4-direction +/-5 frontier used for
1178    /// incremental `line_pattern_ids` maintenance. The maximum is 41
1179    /// (center + 10 cells in each of four directions).
1180    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    /// `mv` 주변 4방향 각 ±5 cell (총 ~30~44 cell-dir 쌍)의 11-cell window
1217    /// pattern ID를 다시 lookup해 cache 갱신. 보드 경계로 일부 잘림.
1218    /// black-relative — read_window의 첫 인자 = black, 둘째 = white.
1219    #[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                // The changed board cell is at window index (5 - offset).
1232                // Index 10 is in the low bits, hence:
1233                // shift = (10 - (5 - offset)) * 2 = (5 + offset) * 2.
1234                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    /// Return whether the stone at `mv` completes a winning line under the active rule.
1255    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    /// `(count, empty terminals, off-board terminals)` of the run through `(row, col)`.
1278    #[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    /// Renju: black may not play a forbidden point (double-four, double-three or overline without an exact five).
1324    /// Omok: neither side may play a double-three.
1325    #[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    /// Whether the side to move can have forbidden points at all (Renju black, either side under Omok).
1338    #[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    /// 게임 결과 확인
1348    pub fn game_result(&self) -> GameResult {
1349        if let Some(mv) = self.last_move {
1350            if self.check_win(mv) {
1351                // 마지막에 둔 사람이 이김 (side_to_move는 이미 넘어간 상태)
1352                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    /// Zobrist 정합성: make/undo가 incremental XOR로 정확히 원복되는지.
1422    #[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        // 모든 중간 키가 unique해야 함 (충돌 없는 상태에서)
1435        let mut sorted = keys.clone();
1436        sorted.sort_unstable();
1437        sorted.dedup();
1438        assert_eq!(sorted.len(), keys.len(), "zobrist sequence collided");
1439
1440        // undo 시 역순으로 정확히 같은 키 복귀
1441        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    /// Pattern4 state cache 정합성: incremental update 결과가 같은 보드를
1456    /// 처음부터 fill_initial_pattern_ids 로 채운 결과와 모든 (cell, dir)에서
1457    /// 동일해야 한다. region recompute가 아닌 진짜 incremental의 핵심 invariant.
1458    #[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        // make_move 각 단계마다 incremental ids == 처음부터 재계산한 ids
1467        for (i, &mv) in moves.iter().enumerate() {
1468            if !board.is_empty(mv) {
1469                continue;
1470            }
1471            board.make_move(mv);
1472
1473            // incremental 후 fresh 보드 재구성 (history replay) + fill_initial 비교
1474            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            // 또는 더 강하게: 직접 처음부터 재구성한 board의 line_pattern_ids
1481            // == 우리 incremental board의 line_pattern_ids
1482            // fresh 도 incremental 사용하므로 다른 검증: 직접 read_window 계산
1483            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        // undo 모두 → initial ids 복원
1512        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        // initial board 와 같은 ids
1519        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        // Exercise the legacy compatibility path: the public flag changes
1663        // effective Freestyle into Standard without changing ordinary
1664        // Zobrist or move count, so the rule component must detect it.
1665        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    /// Release correctness gate: 100k deterministic mixed make/undo
1680    /// operations, lockstep equality with the legacy updater at every step,
1681    /// plus periodic full raw-window rebuild equality.
1682    #[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    /// 수 순서 무관 same position → same zobrist.
1745    /// 두 시퀀스가 같은 final position을 만들면 zobrist도 같아야 함.
1746    #[test]
1747    fn zobrist_path_independence() {
1748        let seq1 = [112, 113, 97, 98]; // B(7,7) W(7,8) B(6,7) W(6,8)
1749        let _seq2 = [112, 98, 97, 113]; // 같은 4 돌, 다른 순서 — 단 흑/백 같은 셀에 두는 순서 보존되어야 함
1750
1751        // seq2 invalid (흑이 (7,7)→(6,8)→(6,7)→(7,8) 순서로 두면 백도 다른 셀)
1752        // 정확한 path-equivalent 짝: 두 흑 수 순서 바꾸기
1753        // seq1: B(112), W(113), B(97), W(98)  → black={112,97}, white={113,98}
1754        // seq2: B(97), W(113), B(112), W(98)  → black={97,112}, white={113,98}  같은 final
1755        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        // 흑: (7,3) (7,4) (7,5) (7,6) (7,7)
1780        // 백: (8,3) (8,4) (8,5) (8,6)
1781        for i in 0..5 {
1782            board.make_move(to_idx(7, 3 + i)); // 흑
1783            if i < 4 {
1784                board.make_move(to_idx(8, 3 + i)); // 백
1785            }
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        // 흑: (0,0) (1,1) (2,2) (3,3) (4,4) — 대각선
1794        // 백: (0,1) (1,2) (2,3) (3,4)
1795        for i in 0..5 {
1796            board.make_move(to_idx(i, i)); // 흑
1797            if i < 4 {
1798                board.make_move(to_idx(i, i + 1)); // 백
1799            }
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)); // 흑
1809            board.make_move(to_idx(8, 3 + i)); // 백
1810        }
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        // Black: .XX. horizontally and vertically through (7,7). White mirrors it at (3,3).
1846        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        // Under Renju the white double-three is legal and black's is forbidden.
1861        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        // Freestyle has no forbidden points.
1866        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    // Caro follows GomocupJudge `rule 9`: exactly five, not blocked at both
1929    // ends by opponent stones; the board edge never blocks.
1930    #[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        // GomocupJudge test_zip_rules.py caro_specific: h8f8g8f9i8f7j8l8,
1956        // black to move. k8 makes g8..k8 blocked by f8/l8 -> no win, so the
1957        // only sensible reply blocks white's f7-f8-f9 three.
1958        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    /// Release composition correctness gate: 100k deterministic mixed
2070    /// make/undo operations, exact ordered-vector equality against the legacy
2071    /// generator, and periodic complete frontier rebuild equality.
2072    #[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}