Skip to main content

figrid_board/
board.rs

1/// 오목 보드 엔진
2///
3/// 15×15 보드. Bitboard 표현 (u128 × 2로 225비트 커버).
4/// 흑(선공)과 백(후공) 각각 bitboard 보유.
5use std::fmt;
6
7pub const BOARD_SIZE: usize = 15;
8pub const NUM_CELLS: usize = BOARD_SIZE * BOARD_SIZE; // 225
9pub const LINE_PATTERN_FRONTIER_MAX: usize = 41;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum Stone {
13    Black,
14    White,
15}
16
17impl Stone {
18    pub fn opponent(self) -> Stone {
19        match self {
20            Stone::Black => Stone::White,
21            Stone::White => Stone::Black,
22        }
23    }
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum RuleSet {
28    Freestyle,
29    Standard,
30    Caro,
31    /// Terminal-line semantics only. Renju forbidden-move legality is not
32    /// implemented yet, so pbrain keeps rejecting Renju games for now.
33    Renju,
34}
35
36impl RuleSet {
37    #[inline]
38    pub const fn uses_exact_five(self) -> bool {
39        matches!(self, RuleSet::Standard | RuleSet::Renju)
40    }
41
42    #[inline]
43    pub const fn line_wins(self, side: Stone, count: u32, open_ends: u32) -> bool {
44        match self {
45            RuleSet::Freestyle => count >= 5,
46            RuleSet::Standard => count == 5,
47            RuleSet::Caro => count >= 6 || (count == 5 && open_ends > 0),
48            RuleSet::Renju => match side {
49                Stone::Black => count == 5,
50                Stone::White => count >= 5,
51            },
52        }
53    }
54}
55
56/// 225비트를 u128 × 2로 표현
57/// lo: 비트 0~127, hi: 비트 128~224
58#[derive(Clone, Copy, PartialEq, Eq)]
59pub struct BitBoard {
60    pub lo: u128,
61    pub hi: u128,
62}
63
64impl BitBoard {
65    pub const EMPTY: Self = Self { lo: 0, hi: 0 };
66
67    #[inline]
68    pub fn get(&self, idx: usize) -> bool {
69        if idx < 128 {
70            (self.lo >> idx) & 1 != 0
71        } else {
72            (self.hi >> (idx - 128)) & 1 != 0
73        }
74    }
75
76    #[inline]
77    pub fn set(&mut self, idx: usize) {
78        if idx < 128 {
79            self.lo |= 1u128 << idx;
80        } else {
81            self.hi |= 1u128 << (idx - 128);
82        }
83    }
84
85    #[inline]
86    pub fn clear(&mut self, idx: usize) {
87        if idx < 128 {
88            self.lo &= !(1u128 << idx);
89        } else {
90            self.hi &= !(1u128 << (idx - 128));
91        }
92    }
93
94    #[inline]
95    pub fn or(&self, other: &BitBoard) -> BitBoard {
96        BitBoard {
97            lo: self.lo | other.lo,
98            hi: self.hi | other.hi,
99        }
100    }
101
102    #[inline]
103    pub fn count_ones(&self) -> u32 {
104        self.lo.count_ones() + self.hi.count_ones()
105    }
106
107    /// Iterate over the indices of set bits, lowest first.
108    /// Enables feature extraction loops to skip empty cells entirely —
109    /// critical when the board is sparse (early/midgame), since a
110    /// stone-driven pass is ~6× cheaper than scanning all 225 cells.
111    #[inline]
112    pub fn iter_ones(&self) -> BitBoardIter {
113        BitBoardIter {
114            lo: self.lo,
115            hi: self.hi,
116        }
117    }
118}
119
120pub struct BitBoardIter {
121    lo: u128,
122    hi: u128,
123}
124
125impl Iterator for BitBoardIter {
126    type Item = usize;
127    #[inline]
128    fn next(&mut self) -> Option<usize> {
129        if self.lo != 0 {
130            let idx = self.lo.trailing_zeros() as usize;
131            self.lo &= self.lo - 1;
132            Some(idx)
133        } else if self.hi != 0 {
134            let idx = 128 + self.hi.trailing_zeros() as usize;
135            self.hi &= self.hi - 1;
136            Some(idx)
137        } else {
138            None
139        }
140    }
141}
142
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub enum GameResult {
145    BlackWin,
146    WhiteWin,
147    Draw,
148    Ongoing,
149}
150
151/// 착수 = 보드 인덱스 (0~224)
152pub type Move = usize;
153
154#[inline]
155pub fn to_rc(idx: usize) -> (usize, usize) {
156    (idx / BOARD_SIZE, idx % BOARD_SIZE)
157}
158
159#[inline]
160pub fn to_idx(row: usize, col: usize) -> usize {
161    row * BOARD_SIZE + col
162}
163
164#[inline]
165fn in_board(row: i32, col: i32) -> bool {
166    row >= 0 && row < BOARD_SIZE as i32 && col >= 0 && col < BOARD_SIZE as i32
167}
168
169/// Zobrist 키 — 보드 상태의 고유 해시.
170/// `(cell, color)` 별로 고정 random u64를 XOR 해서 만든다.
171/// `side_to_move` 도 별도 키로 toggle. make/undo 시 incremental XOR 갱신.
172mod zobrist {
173    use super::{NUM_CELLS, Stone};
174
175    /// 결정적이지만 잘 분산된 splitmix64 변형으로 컴파일 타임 키 생성.
176    pub(super) const fn splitmix64(seed: u64) -> u64 {
177        let mut x = seed;
178        x = x.wrapping_add(0x9E3779B97F4A7C15);
179        x = (x ^ (x >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
180        x = (x ^ (x >> 27)).wrapping_mul(0x94D049BB133111EB);
181        x ^ (x >> 31)
182    }
183
184    const fn build_keys() -> [[u64; NUM_CELLS]; 2] {
185        let mut out = [[0u64; NUM_CELLS]; 2];
186        let mut color = 0;
187        while color < 2 {
188            let mut cell = 0;
189            while cell < NUM_CELLS {
190                let seed = (color as u64) * 0x9E3779B97F4A7C15 ^ (cell as u64);
191                out[color][cell] = splitmix64(seed);
192                cell += 1;
193            }
194            color += 1;
195        }
196        out
197    }
198
199    pub const STONE_KEYS: [[u64; NUM_CELLS]; 2] = build_keys();
200    pub const SIDE_TO_MOVE_KEY: u64 = splitmix64(0xCAFE_BABE_DEAD_BEEF);
201
202    #[inline]
203    pub const fn key_for(stone: Stone, cell: usize) -> u64 {
204        let color = match stone {
205            Stone::Black => 0,
206            Stone::White => 1,
207        };
208        STONE_KEYS[color][cell]
209    }
210}
211
212pub use zobrist::SIDE_TO_MOVE_KEY as ZOBRIST_SIDE;
213
214#[inline]
215pub const fn zobrist_stone_key(stone: Stone, cell: usize) -> u64 {
216    zobrist::key_for(stone, cell)
217}
218
219/// Domain-separation key for the effective game rule in the D4 hash sidecar.
220///
221/// These seeds are frozen by the CB-GH0 preregistration. Keep this function
222/// tied to the board's existing SplitMix64 implementation so the construction
223/// cannot silently drift between the ordinary and D4 Zobrist families.
224#[inline]
225pub const fn d4_rule_key(rule: RuleSet) -> u64 {
226    let seed = match rule {
227        RuleSet::Freestyle => 0xD4C0_0000_0000_0000,
228        RuleSet::Standard => 0xD4C0_0000_0000_0001,
229        RuleSet::Caro => 0xD4C0_0000_0000_0002,
230        RuleSet::Renju => 0xD4C0_0000_0000_0003,
231    };
232    zobrist::splitmix64(seed)
233}
234
235/// 4 directional 11-cell line pattern mapped IDs per cell.
236/// Pattern4 mini의 incremental state cache. 값 ∈ [0, PATTERN_NUM_IDS)
237/// (= 0..PATTERN_NUM_IDS): swap-closed mapped ids plus rare bucket. u16에 들어감.
238///
239/// Black-relative storage: 1=black, 2=white로 read_window. side_to_move
240/// 변경에 따라 ID 재계산 안 함 (perspective 변환은 NNUE feature 매핑
241/// 단계에서 처리).
242pub type LinePatternState = Box<[[u16; 4]; NUM_CELLS]>;
243
244const NO_CANDIDATE_SOURCE: u8 = u8::MAX;
245
246/// Optional radius-2 candidate frontier.
247///
248/// `by_min_source[s]` contains exactly the empty candidates whose lowest
249/// occupied radius-2 neighbor is `s`. Iterating non-empty buckets and then
250/// their bits in ascending order reproduces the legacy discovery order.
251#[derive(Clone)]
252struct CandidateFrontierState {
253    radius2_count: [u8; NUM_CELLS],
254    candidates: BitBoard,
255    min_source: [u8; NUM_CELLS],
256    by_min_source: [BitBoard; NUM_CELLS],
257    nonempty_sources: BitBoard,
258}
259
260impl CandidateFrontierState {
261    fn empty() -> Self {
262        Self {
263            radius2_count: [0; NUM_CELLS],
264            candidates: BitBoard::EMPTY,
265            min_source: [NO_CANDIDATE_SOURCE; NUM_CELLS],
266            by_min_source: [BitBoard::EMPTY; NUM_CELLS],
267            nonempty_sources: BitBoard::EMPTY,
268        }
269    }
270
271    #[inline]
272    fn bucket_insert(&mut self, source: usize, cell: usize) {
273        let bucket = &mut self.by_min_source[source];
274        let was_empty = bucket.lo == 0 && bucket.hi == 0;
275        bucket.set(cell);
276        if was_empty {
277            self.nonempty_sources.set(source);
278        }
279    }
280
281    #[inline]
282    fn bucket_remove(&mut self, source: usize, cell: usize) {
283        let bucket = &mut self.by_min_source[source];
284        debug_assert!(bucket.get(cell));
285        bucket.clear(cell);
286        if bucket.lo == 0 && bucket.hi == 0 {
287            self.nonempty_sources.clear(source);
288        }
289    }
290
291    #[inline]
292    fn candidate_insert(&mut self, source: usize, cell: usize) {
293        debug_assert!(!self.candidates.get(cell));
294        debug_assert_eq!(self.min_source[cell], NO_CANDIDATE_SOURCE);
295        self.candidates.set(cell);
296        self.min_source[cell] = source as u8;
297        self.bucket_insert(source, cell);
298    }
299
300    #[inline]
301    fn candidate_remove(&mut self, cell: usize) {
302        debug_assert!(self.candidates.get(cell));
303        let source = self.min_source[cell] as usize;
304        debug_assert!(source < NUM_CELLS);
305        self.bucket_remove(source, cell);
306        self.candidates.clear(cell);
307        self.min_source[cell] = NO_CANDIDATE_SOURCE;
308    }
309
310    #[inline]
311    fn candidate_rekey(&mut self, new_source: usize, cell: usize) {
312        debug_assert!(self.candidates.get(cell));
313        let old_source = self.min_source[cell] as usize;
314        debug_assert!(old_source < NUM_CELLS);
315        self.bucket_remove(old_source, cell);
316        self.min_source[cell] = new_source as u8;
317        self.bucket_insert(new_source, cell);
318    }
319}
320
321/// Incrementally maintained raw 22-bit Pattern4 windows.
322///
323/// Entries use the same black-relative layout as `pack_window`: window
324/// index 0 occupies bits 21..20 and index 10 occupies bits 1..0.
325pub type LinePackedWindowState = Box<[[u32; 4]; NUM_CELLS]>;
326
327/// Search-only acceleration state kept outside [`Board`].
328///
329/// This sidecar deliberately preserves the exact public `Board` field shape
330/// from figrid-board 0.8.1, so downstream exhaustive struct literals remain
331/// source-compatible. Callers that opt in must route every searched
332/// make/undo through this state while it is enabled.
333#[doc(hidden)]
334#[derive(Clone, Default)]
335pub struct BoardSearchState {
336    line_packed_windows: Option<LinePackedWindowState>,
337    candidate_frontier: Option<Box<CandidateFrontierState>>,
338    d4_hash: Option<crate::d4_hash::D4HashState>,
339    synchronized_position: Option<(u64, usize, RuleSet)>,
340}
341
342impl BoardSearchState {
343    pub fn new() -> Self {
344        Self::default()
345    }
346
347    /// Whether every enabled cache describes the supplied board position.
348    #[inline]
349    pub fn is_synchronized(&self, board: &Board) -> bool {
350        if self.line_packed_windows.is_none()
351            && self.candidate_frontier.is_none()
352            && self.d4_hash.is_none()
353        {
354            true
355        } else {
356            self.synchronized_position
357                == Some((board.zobrist, board.move_count, board.effective_rule_set()))
358        }
359    }
360
361    /// Rebuild all currently enabled caches if the board changed outside the
362    /// sidecar (for example, between two searches).
363    pub fn synchronize(&mut self, board: &Board) {
364        if self.is_synchronized(board) {
365            return;
366        }
367        let packed_enabled = self.line_packed_windows.is_some();
368        let frontier_enabled = self.candidate_frontier.is_some();
369        let d4_hash_enabled = self.d4_hash.is_some();
370        self.line_packed_windows = None;
371        self.candidate_frontier = None;
372        self.d4_hash = None;
373        self.synchronized_position = None;
374        if packed_enabled {
375            self.set_packed_line_windows_enabled(board, true);
376        }
377        if frontier_enabled {
378            self.set_candidate_frontier_enabled(board, true);
379        }
380        if d4_hash_enabled {
381            self.set_d4_hash_enabled(board, true);
382        }
383    }
384
385    #[inline]
386    fn record_position(&mut self, board: &Board) {
387        self.synchronized_position = (self.line_packed_windows.is_some()
388            || self.candidate_frontier.is_some()
389            || self.d4_hash.is_some())
390        .then_some((board.zobrist, board.move_count, board.effective_rule_set()));
391    }
392
393    /// Enable or disable incremental packed Pattern4 windows.
394    ///
395    /// Enabling performs one full rebuild at the supplied root. Descendant
396    /// make/undo operations routed through this sidecar then update only one
397    /// 2-bit slot per affected window.
398    pub fn set_packed_line_windows_enabled(&mut self, board: &Board, enabled: bool) {
399        self.synchronize(board);
400        if enabled {
401            if self.line_packed_windows.is_none() {
402                let mut windows = Box::new([[0u32; 4]; NUM_CELLS]);
403                const DIRS: [(i32, i32); 4] = [(0, 1), (1, 0), (1, 1), (1, -1)];
404                for cell in 0..NUM_CELLS {
405                    let row = (cell / BOARD_SIZE) as i32;
406                    let col = (cell % BOARD_SIZE) as i32;
407                    for (dir_idx, &(dr, dc)) in DIRS.iter().enumerate() {
408                        let window = crate::pattern_table::read_window(
409                            &board.black,
410                            &board.white,
411                            row,
412                            col,
413                            dr,
414                            dc,
415                        );
416                        let packed = crate::pattern_table::pack_window(&window);
417                        debug_assert_eq!(
418                            board.line_pattern_ids[cell][dir_idx],
419                            crate::pattern_table::lookup_mapped_id(packed),
420                            "line pattern ID stale before packed-window enable"
421                        );
422                        windows[cell][dir_idx] = packed;
423                    }
424                }
425                self.line_packed_windows = Some(windows);
426            }
427        } else {
428            self.line_packed_windows = None;
429        }
430        self.record_position(board);
431    }
432
433    #[inline]
434    pub fn packed_line_windows_enabled(&self) -> bool {
435        self.line_packed_windows.is_some()
436    }
437
438    /// Expose one packed value for correctness and audit harnesses.
439    #[inline]
440    pub fn packed_line_window(&self, cell: usize, dir_idx: usize) -> Option<u32> {
441        self.line_packed_windows
442            .as_ref()
443            .map(|windows| windows[cell][dir_idx])
444    }
445
446    /// Enable or disable the exact-order incremental candidate frontier.
447    ///
448    /// Enabling performs one full rebuild at the supplied root.
449    pub fn set_candidate_frontier_enabled(&mut self, board: &Board, enabled: bool) {
450        self.synchronize(board);
451        if enabled {
452            if self.candidate_frontier.is_none() {
453                self.candidate_frontier = Some(board.rebuild_candidate_frontier());
454            }
455        } else {
456            self.candidate_frontier = None;
457        }
458        self.record_position(board);
459    }
460
461    #[inline]
462    pub fn candidate_frontier_enabled(&self) -> bool {
463        self.candidate_frontier.is_some()
464    }
465
466    /// Enable or disable the default-off incremental D4 hash sidecar.
467    ///
468    /// Enabling rebuilds all eight transformed hashes from the supplied root.
469    /// The ordinary public [`Board`] layout remains unchanged.
470    #[doc(hidden)]
471    pub fn set_d4_hash_enabled(&mut self, board: &Board, enabled: bool) {
472        self.synchronize(board);
473        if enabled {
474            if self.d4_hash.is_none() {
475                self.d4_hash = Some(crate::d4_hash::D4HashState::rebuild(board));
476            }
477        } else {
478            self.d4_hash = None;
479        }
480        self.record_position(board);
481    }
482
483    #[doc(hidden)]
484    #[inline]
485    pub fn d4_hash_enabled(&self) -> bool {
486        self.d4_hash.is_some()
487    }
488
489    /// Return a copy of the eight transformed hashes only when this sidecar
490    /// is enabled and synchronized to `board`.
491    #[doc(hidden)]
492    #[inline]
493    pub fn d4_hashes(&self, board: &Board) -> Option<[u64; 8]> {
494        if !self.is_synchronized(board) {
495            return None;
496        }
497        self.d4_hash.as_ref().map(|state| *state.hashes())
498    }
499
500    /// Return the current canonical hash context only for synchronized state.
501    #[doc(hidden)]
502    #[inline]
503    pub fn d4_canonical_context(&self, board: &Board) -> Option<crate::d4_hash::CanonicalContext> {
504        if !self.is_synchronized(board) {
505            return None;
506        }
507        self.d4_hash
508            .as_ref()
509            .map(crate::d4_hash::D4HashState::canonical_context)
510    }
511
512    /// Predict all child hashes without mutating either the board or sidecar.
513    #[doc(hidden)]
514    #[inline]
515    pub fn d4_predicted_child_hashes(&self, board: &Board, mv: Move) -> Option<[u64; 8]> {
516        if !self.is_synchronized(board) {
517            return None;
518        }
519        self.d4_hash.as_ref()?.predicted_child_hashes(board, mv)
520    }
521
522    /// Predict the child's canonical hash context without mutation.
523    #[doc(hidden)]
524    #[inline]
525    pub fn d4_predicted_child_context(
526        &self,
527        board: &Board,
528        mv: Move,
529    ) -> Option<crate::d4_hash::CanonicalContext> {
530        if !self.is_synchronized(board) {
531            return None;
532        }
533        self.d4_hash.as_ref()?.predicted_child_context(board, mv)
534    }
535
536    /// Generate candidates in exactly the legacy discovery order.
537    pub fn candidate_moves(&self, board: &Board) -> Vec<Move> {
538        if !self.is_synchronized(board) {
539            return board.candidate_moves();
540        }
541        self.candidate_moves_synchronized(board)
542    }
543
544    #[inline]
545    pub(crate) fn candidate_moves_synchronized(&self, board: &Board) -> Vec<Move> {
546        debug_assert!(
547            self.is_synchronized(board),
548            "BoardSearchState is stale for candidate generation"
549        );
550        if board.move_count == 0 {
551            return vec![to_idx(7, 7)];
552        }
553        let Some(frontier) = self.candidate_frontier.as_ref() else {
554            return board.candidate_moves();
555        };
556        board.candidate_moves_from_frontier(frontier)
557    }
558
559    /// Apply a move while maintaining every enabled sidecar cache.
560    #[inline]
561    pub fn make_move(&mut self, board: &mut Board, mv: Move) {
562        self.synchronize(board);
563        self.make_move_synchronized(board, mv);
564    }
565
566    #[inline]
567    pub(crate) fn make_move_synchronized(&mut self, board: &mut Board, mv: Move) {
568        debug_assert!(
569            self.is_synchronized(board),
570            "BoardSearchState is stale before make_move"
571        );
572        let placed = board.side_to_move;
573        board.make_move_with_search_state(
574            mv,
575            self.line_packed_windows.as_deref_mut(),
576            self.candidate_frontier.as_deref_mut(),
577        );
578        if let Some(d4_hash) = self.d4_hash.as_mut() {
579            d4_hash.apply_move(placed, mv);
580        }
581        self.record_position(board);
582    }
583
584    /// Undo a move while maintaining every enabled sidecar cache.
585    #[inline]
586    pub fn undo_move(&mut self, board: &mut Board) {
587        self.synchronize(board);
588        self.undo_move_synchronized(board);
589    }
590
591    #[inline]
592    pub(crate) fn undo_move_synchronized(&mut self, board: &mut Board) {
593        debug_assert!(
594            self.is_synchronized(board),
595            "BoardSearchState is stale before undo_move"
596        );
597        let removed = board
598            .history
599            .last()
600            .copied()
601            .map(|mv| (board.side_to_move.opponent(), mv));
602        board.undo_move_with_search_state(
603            self.line_packed_windows.as_deref_mut(),
604            self.candidate_frontier.as_deref_mut(),
605        );
606        if let (Some(d4_hash), Some((placed, mv))) = (self.d4_hash.as_mut(), removed) {
607            d4_hash.apply_move(placed, mv);
608        }
609        self.record_position(board);
610    }
611}
612
613#[derive(Clone)]
614pub struct Board {
615    pub black: BitBoard,
616    pub white: BitBoard,
617    pub side_to_move: Stone,
618    pub move_count: usize,
619    pub last_move: Option<Move>,
620    /// 착수 이력 (undo를 위해)
621    pub history: Vec<Move>,
622    /// Zobrist 해시. make_move/undo_move에서 XOR로 incremental 갱신.
623    /// 보드 상태(돌 배치 + side_to_move)의 64-bit fingerprint —
624    /// transposition table 키로 사용.
625    pub zobrist: u64,
626    /// Pattern4 mini state cache. 각 (cell, dir) 11-cell 윈도우의
627    /// canonical pattern ID (black-relative). 빈 보드는 모두 ID 0
628    /// (empty_pattern_id). make/undo가 영향받는 cell의 ID만 lookup으로
629    /// 재계산해 region recompute를 피함. NNUE feature 매핑은 Phase 3에서.
630    pub line_pattern_ids: LinePatternState,
631    /// Active Gomoku rule set for terminal line checks.
632    pub rule_set: RuleSet,
633    /// Backward-compatible Standard-rule flag used by older tools.
634    /// Prefer `set_rule_set` for new code.
635    pub exact5: bool,
636}
637
638impl Board {
639    pub fn new() -> Self {
640        let mut b = Self {
641            black: BitBoard::EMPTY,
642            white: BitBoard::EMPTY,
643            side_to_move: Stone::Black,
644            move_count: 0,
645            last_move: None,
646            history: Vec::with_capacity(NUM_CELLS),
647            // 빈 보드 + Black to move 의 zobrist 는 0.
648            zobrist: 0,
649            // 정확한 초기값은 fill_initial_pattern_ids 에서 채움 (가장자리는
650            // boundary 포함이라 ID ≠ 0).
651            rule_set: RuleSet::Freestyle,
652            line_pattern_ids: Box::new([[0u16; 4]; NUM_CELLS]),
653            exact5: false,
654        };
655        b.fill_initial_pattern_ids();
656        b
657    }
658
659    #[inline]
660    pub fn set_rule_set(&mut self, rule_set: RuleSet) {
661        self.rule_set = rule_set;
662        self.exact5 = rule_set.uses_exact_five();
663    }
664
665    #[inline]
666    pub fn effective_rule_set(&self) -> RuleSet {
667        // Backward compatibility for older tools that still do
668        // `board.exact5 = true` after Board::new().
669        if self.exact5 && matches!(self.rule_set, RuleSet::Freestyle) {
670            RuleSet::Standard
671        } else {
672            self.rule_set
673        }
674    }
675
676    /// 빈 보드 기준 모든 (cell, dir) line pattern mapped ID를 lookup해 채움.
677    /// new() 에서만 호출. 가장자리 cell은 boundary 포함 패턴이라 빈 cell의
678    /// 안쪽 ID(보통 0)과 다른 mapped ID로 채워짐.
679    fn fill_initial_pattern_ids(&mut self) {
680        const DIRS: [(i32, i32); 4] = [(0, 1), (1, 0), (1, 1), (1, -1)];
681        for cell in 0..NUM_CELLS {
682            let row = (cell / BOARD_SIZE) as i32;
683            let col = (cell % BOARD_SIZE) as i32;
684            for (dir_idx, &(dr, dc)) in DIRS.iter().enumerate() {
685                let w =
686                    crate::pattern_table::read_window(&self.black, &self.white, row, col, dr, dc);
687                let packed = crate::pattern_table::pack_window(&w);
688                self.line_pattern_ids[cell][dir_idx] =
689                    crate::pattern_table::lookup_mapped_id(packed);
690            }
691        }
692    }
693
694    /// 해당 칸이 비어있는지
695    #[inline]
696    pub fn is_empty(&self, idx: usize) -> bool {
697        let occupied = self.black.or(&self.white);
698        !occupied.get(idx)
699    }
700
701    /// 현재 턴의 돌 bitboard
702    #[inline]
703    pub fn current_stones(&self) -> &BitBoard {
704        match self.side_to_move {
705            Stone::Black => &self.black,
706            Stone::White => &self.white,
707        }
708    }
709
710    /// 상대 턴의 돌 bitboard
711    #[inline]
712    pub fn opponent_stones(&self) -> &BitBoard {
713        match self.side_to_move {
714            Stone::Black => &self.white,
715            Stone::White => &self.black,
716        }
717    }
718
719    /// 합법 수 목록 생성
720    pub fn legal_moves(&self) -> Vec<Move> {
721        let occupied = self.black.or(&self.white);
722        let mut moves = Vec::with_capacity(NUM_CELLS - self.move_count);
723        for idx in 0..NUM_CELLS {
724            if !occupied.get(idx) {
725                moves.push(idx);
726            }
727        }
728        moves
729    }
730
731    #[inline]
732    fn for_radius2_neighbors(cell: usize, mut f: impl FnMut(usize)) {
733        let (row, col) = to_rc(cell);
734        let row_start = row.saturating_sub(2);
735        let row_end = (row + 2).min(BOARD_SIZE - 1);
736        let col_start = col.saturating_sub(2);
737        let col_end = (col + 2).min(BOARD_SIZE - 1);
738        for neighbor_row in row_start..=row_end {
739            for neighbor_col in col_start..=col_end {
740                let neighbor = to_idx(neighbor_row, neighbor_col);
741                if neighbor != cell {
742                    f(neighbor);
743                }
744            }
745        }
746    }
747
748    #[inline]
749    fn first_radius2_source(occupied: &BitBoard, cell: usize) -> Option<usize> {
750        let mut first = None;
751        Self::for_radius2_neighbors(cell, |neighbor| {
752            if first.is_none() && occupied.get(neighbor) {
753                first = Some(neighbor);
754            }
755        });
756        first
757    }
758
759    fn rebuild_candidate_frontier(&self) -> Box<CandidateFrontierState> {
760        let occupied = self.black.or(&self.white);
761        let mut state = Box::new(CandidateFrontierState::empty());
762        for source in occupied.iter_ones() {
763            Self::for_radius2_neighbors(source, |cell| {
764                state.radius2_count[cell] += 1;
765            });
766        }
767        for cell in 0..NUM_CELLS {
768            if !occupied.get(cell) && state.radius2_count[cell] > 0 {
769                let source = Self::first_radius2_source(&occupied, cell)
770                    .expect("positive radius-2 count must have a source");
771                state.candidate_insert(source, cell);
772            }
773        }
774        state
775    }
776
777    fn candidate_moves_legacy(&self) -> Vec<Move> {
778        let occupied = self.black.or(&self.white);
779        let mut seen = [false; NUM_CELLS];
780        let mut moves = Vec::with_capacity(64);
781
782        for idx in 0..NUM_CELLS {
783            if !occupied.get(idx) {
784                continue;
785            }
786            let (r, c) = to_rc(idx);
787            for dr in -2i32..=2 {
788                for dc in -2i32..=2 {
789                    if dr == 0 && dc == 0 {
790                        continue;
791                    }
792                    let nr = r as i32 + dr;
793                    let nc = c as i32 + dc;
794                    if nr < 0 || nr >= BOARD_SIZE as i32 || nc < 0 || nc >= BOARD_SIZE as i32 {
795                        continue;
796                    }
797                    let nidx = to_idx(nr as usize, nc as usize);
798                    if !seen[nidx] && !occupied.get(nidx) {
799                        seen[nidx] = true;
800                        moves.push(nidx);
801                    }
802                }
803            }
804        }
805
806        moves
807    }
808
809    /// 빈 칸 주변(2칸 이내)만 후보로 생성 — 탐색 효율화
810    pub fn candidate_moves(&self) -> Vec<Move> {
811        if self.move_count == 0 {
812            // 첫 수: 천원
813            return vec![to_idx(7, 7)];
814        }
815        return self.candidate_moves_legacy();
816    }
817
818    fn candidate_moves_from_frontier(&self, frontier: &CandidateFrontierState) -> Vec<Move> {
819        let mut moves = Vec::with_capacity(frontier.candidates.count_ones() as usize);
820        for source in frontier.nonempty_sources.iter_ones() {
821            moves.extend(frontier.by_min_source[source].iter_ones());
822        }
823        debug_assert_eq!(moves.len(), frontier.candidates.count_ones() as usize);
824        moves
825    }
826
827    #[inline]
828    fn update_candidate_frontier_after_make(
829        &self,
830        frontier: &mut CandidateFrontierState,
831        mv: Move,
832    ) {
833        let occupied = self.black.or(&self.white);
834
835        if frontier.candidates.get(mv) {
836            frontier.candidate_remove(mv);
837        }
838        Self::for_radius2_neighbors(mv, |cell| {
839            let old_count = frontier.radius2_count[cell];
840            debug_assert!(old_count < 24);
841            frontier.radius2_count[cell] = old_count + 1;
842            if occupied.get(cell) {
843                return;
844            }
845            if old_count == 0 {
846                frontier.candidate_insert(mv, cell);
847            } else {
848                let old_source = frontier.min_source[cell] as usize;
849                debug_assert!(old_source < NUM_CELLS);
850                if mv < old_source {
851                    frontier.candidate_rekey(mv, cell);
852                }
853            }
854        });
855    }
856
857    #[inline]
858    fn update_candidate_frontier_after_undo(
859        &self,
860        frontier: &mut CandidateFrontierState,
861        mv: Move,
862    ) {
863        let occupied = self.black.or(&self.white);
864
865        Self::for_radius2_neighbors(mv, |cell| {
866            let old_count = frontier.radius2_count[cell];
867            debug_assert!(old_count > 0);
868            frontier.radius2_count[cell] = old_count - 1;
869            if occupied.get(cell) {
870                return;
871            }
872            if old_count == 1 {
873                frontier.candidate_remove(cell);
874            } else if frontier.min_source[cell] as usize == mv {
875                let new_source = Self::first_radius2_source(&occupied, cell)
876                    .expect("remaining radius-2 count must have a source");
877                frontier.candidate_rekey(new_source, cell);
878            }
879        });
880
881        if frontier.radius2_count[mv] > 0 {
882            let source = Self::first_radius2_source(&occupied, mv)
883                .expect("newly empty candidate must have a radius-2 source");
884            frontier.candidate_insert(source, mv);
885        }
886    }
887
888    /// 착수
889    pub fn make_move(&mut self, mv: Move) {
890        self.make_move_with_search_state(mv, None, None);
891    }
892
893    #[inline]
894    fn make_move_with_search_state(
895        &mut self,
896        mv: Move,
897        line_packed_windows: Option<&mut [[u32; 4]; NUM_CELLS]>,
898        candidate_frontier: Option<&mut CandidateFrontierState>,
899    ) {
900        debug_assert!(mv < NUM_CELLS);
901        debug_assert!(self.is_empty(mv));
902
903        let placed = self.side_to_move;
904        match placed {
905            Stone::Black => self.black.set(mv),
906            Stone::White => self.white.set(mv),
907        }
908        // Zobrist incremental: 새 돌의 (color, cell) 키 XOR + side toggle.
909        self.zobrist ^= zobrist_stone_key(placed, mv);
910        self.zobrist ^= ZOBRIST_SIDE;
911
912        self.history.push(mv);
913        self.last_move = Some(mv);
914        self.move_count += 1;
915        self.side_to_move = placed.opponent();
916
917        // Maintain the optional radius-2 frontier after the stone is visible.
918        if let Some(frontier) = candidate_frontier {
919            self.update_candidate_frontier_after_make(frontier, mv);
920        }
921
922        // Pattern4 mini state cache: mv 주변 4방향 ±5 cell의 pattern_id 갱신.
923        // black-relative: read_window의 첫 인자 = black. side_to_move 무관.
924        self.update_line_patterns_around(
925            mv,
926            match placed {
927                Stone::Black => 1,
928                Stone::White => 2,
929            },
930            line_packed_windows,
931        );
932    }
933
934    /// 착수 취소
935    pub fn undo_move(&mut self) {
936        self.undo_move_with_search_state(None, None);
937    }
938
939    #[inline]
940    fn undo_move_with_search_state(
941        &mut self,
942        line_packed_windows: Option<&mut [[u32; 4]; NUM_CELLS]>,
943        candidate_frontier: Option<&mut CandidateFrontierState>,
944    ) {
945        if let Some(mv) = self.history.pop() {
946            self.side_to_move = self.side_to_move.opponent();
947            let placed = self.side_to_move;
948            self.move_count -= 1;
949            match placed {
950                Stone::Black => self.black.clear(mv),
951                Stone::White => self.white.clear(mv),
952            }
953            // Zobrist는 XOR이라 같은 키 한 번 더 적용 = 원복.
954            self.zobrist ^= zobrist_stone_key(placed, mv);
955            self.zobrist ^= ZOBRIST_SIDE;
956
957            self.last_move = self.history.last().copied();
958
959            // The stone is already cleared, so restore the inverse radius-2
960            // delta and reinsert `mv` when another stone reaches it.
961            if let Some(frontier) = candidate_frontier {
962                self.update_candidate_frontier_after_undo(frontier, mv);
963            }
964
965            // Pattern4 state cache: mv 주변 4방향 ±5 cell 다시 read+lookup.
966            // mv는 이미 cleared된 상태라 새 윈도우에서 mv = empty.
967            self.update_line_patterns_around(mv, 0, line_packed_windows);
968        }
969    }
970
971    /// Unique cells touched by the same 4-direction +/-5 frontier used for
972    /// incremental `line_pattern_ids` maintenance. The maximum is 41
973    /// (center + 10 cells in each of four directions).
974    pub fn line_pattern_dirty_cells(
975        mv: Move,
976        out: &mut [usize; LINE_PATTERN_FRONTIER_MAX],
977    ) -> usize {
978        let mut seen = [false; NUM_CELLS];
979        let mut len = 0usize;
980        Self::for_line_pattern_frontier(mv, |cell, _dir_idx, _offset| {
981            if !seen[cell] {
982                seen[cell] = true;
983                debug_assert!(len < LINE_PATTERN_FRONTIER_MAX);
984                out[len] = cell;
985                len += 1;
986            }
987        });
988        len
989    }
990
991    #[inline]
992    fn for_line_pattern_frontier(mut mv: Move, mut f: impl FnMut(usize, usize, i32)) {
993        const DIRS: [(i32, i32); 4] = [(0, 1), (1, 0), (1, 1), (1, -1)];
994        debug_assert!(mv < NUM_CELLS);
995        let row = (mv / BOARD_SIZE) as i32;
996        let col = (mv % BOARD_SIZE) as i32;
997        for (dir_idx, &(dr, dc)) in DIRS.iter().enumerate() {
998            for offset in -5i32..=5 {
999                let r = row + dr * offset;
1000                let c = col + dc * offset;
1001                if r < 0 || r >= BOARD_SIZE as i32 || c < 0 || c >= BOARD_SIZE as i32 {
1002                    continue;
1003                }
1004                mv = (r as usize) * BOARD_SIZE + c as usize;
1005                f(mv, dir_idx, offset);
1006            }
1007        }
1008    }
1009
1010    /// `mv` 주변 4방향 각 ±5 cell (총 ~30~44 cell-dir 쌍)의 11-cell window
1011    /// pattern ID를 다시 lookup해 cache 갱신. 보드 경계로 일부 잘림.
1012    /// black-relative — read_window의 첫 인자 = black, 둘째 = white.
1013    #[inline]
1014    fn update_line_patterns_around(
1015        &mut self,
1016        mv: Move,
1017        new_cell: u32,
1018        line_packed_windows: Option<&mut [[u32; 4]; NUM_CELLS]>,
1019    ) {
1020        const DIRS: [(i32, i32); 4] = [(0, 1), (1, 0), (1, 1), (1, -1)];
1021        debug_assert!(new_cell <= 2);
1022        if let Some(windows) = line_packed_windows {
1023            let ids = &mut self.line_pattern_ids;
1024            Self::for_line_pattern_frontier(mv, |cell, dir_idx, offset| {
1025                // The changed board cell is at window index (5 - offset).
1026                // Index 10 is in the low bits, hence:
1027                // shift = (10 - (5 - offset)) * 2 = (5 + offset) * 2.
1028                let shift = ((5 + offset) * 2) as u32;
1029                let mask = 0b11u32 << shift;
1030                let packed = (windows[cell][dir_idx] & !mask) | (new_cell << shift);
1031                windows[cell][dir_idx] = packed;
1032                ids[cell][dir_idx] = crate::pattern_table::lookup_mapped_id(packed);
1033            });
1034        } else {
1035            Self::for_line_pattern_frontier(mv, |cell, dir_idx, _offset| {
1036                let (dr, dc) = DIRS[dir_idx];
1037                let r = (cell / BOARD_SIZE) as i32;
1038                let c = (cell % BOARD_SIZE) as i32;
1039                let window =
1040                    crate::pattern_table::read_window(&self.black, &self.white, r, c, dr, dc);
1041                let packed = crate::pattern_table::pack_window(&window);
1042                self.line_pattern_ids[cell][dir_idx] =
1043                    crate::pattern_table::lookup_mapped_id(packed);
1044            });
1045        }
1046    }
1047
1048    /// Return whether the stone at `mv` completes a winning line under the active rule.
1049    pub fn check_win(&self, mv: Move) -> bool {
1050        let (row, col) = to_rc(mv);
1051        let (side, stone) = if self.black.get(mv) {
1052            (Stone::Black, &self.black)
1053        } else if self.white.get(mv) {
1054            (Stone::White, &self.white)
1055        } else {
1056            return false;
1057        };
1058        let rules = self.effective_rule_set();
1059
1060        let directions: [(i32, i32); 4] = [(0, 1), (1, 0), (1, 1), (1, -1)];
1061        for &(dr, dc) in &directions {
1062            let (count, open_ends) = self.line_run(stone, row as i32, col as i32, dr, dc);
1063            if rules.line_wins(side, count, open_ends) {
1064                return true;
1065            }
1066        }
1067        false
1068    }
1069
1070    #[inline]
1071    fn line_run(&self, stone: &BitBoard, row: i32, col: i32, dr: i32, dc: i32) -> (u32, u32) {
1072        let mut count = 1u32;
1073        let mut open_ends = 0u32;
1074
1075        let mut r = row + dr;
1076        let mut c = col + dc;
1077        while in_board(r, c) && stone.get(to_idx(r as usize, c as usize)) {
1078            count += 1;
1079            r += dr;
1080            c += dc;
1081        }
1082        if in_board(r, c) && self.is_empty(to_idx(r as usize, c as usize)) {
1083            open_ends += 1;
1084        }
1085
1086        let mut r = row - dr;
1087        let mut c = col - dc;
1088        while in_board(r, c) && stone.get(to_idx(r as usize, c as usize)) {
1089            count += 1;
1090            r -= dr;
1091            c -= dc;
1092        }
1093        if in_board(r, c) && self.is_empty(to_idx(r as usize, c as usize)) {
1094            open_ends += 1;
1095        }
1096
1097        (count, open_ends)
1098    }
1099
1100    #[inline]
1101    pub fn is_legal_move(&self, mv: Move) -> bool {
1102        mv < NUM_CELLS && self.is_empty(mv)
1103    }
1104
1105    /// 게임 결과 확인
1106    pub fn game_result(&self) -> GameResult {
1107        if let Some(mv) = self.last_move {
1108            if self.check_win(mv) {
1109                // 마지막에 둔 사람이 이김 (side_to_move는 이미 넘어간 상태)
1110                return match self.side_to_move {
1111                    Stone::Black => GameResult::WhiteWin,
1112                    Stone::White => GameResult::BlackWin,
1113                };
1114            }
1115        }
1116        if self.move_count >= NUM_CELLS {
1117            GameResult::Draw
1118        } else {
1119            GameResult::Ongoing
1120        }
1121    }
1122}
1123
1124impl fmt::Display for Board {
1125    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1126        write!(f, "   ")?;
1127        for c in 0..BOARD_SIZE {
1128            write!(f, "{:2}", (b'A' + c as u8) as char)?;
1129        }
1130        writeln!(f)?;
1131
1132        for r in 0..BOARD_SIZE {
1133            write!(f, "{:2} ", r + 1)?;
1134            for c in 0..BOARD_SIZE {
1135                let idx = to_idx(r, c);
1136                if self.black.get(idx) {
1137                    write!(f, " X")?;
1138                } else if self.white.get(idx) {
1139                    write!(f, " O")?;
1140                } else {
1141                    write!(f, " .")?;
1142                }
1143            }
1144            writeln!(f)?;
1145        }
1146        Ok(())
1147    }
1148}
1149
1150#[cfg(test)]
1151mod tests {
1152    use super::*;
1153
1154    fn put_stone(board: &mut Board, side: Stone, row: usize, col: usize) -> Move {
1155        let mv = to_idx(row, col);
1156        assert!(board.is_empty(mv));
1157        match side {
1158            Stone::Black => board.black.set(mv),
1159            Stone::White => board.white.set(mv),
1160        }
1161        board.move_count += 1;
1162        mv
1163    }
1164
1165    #[test]
1166    fn test_make_undo_move() {
1167        let mut board = Board::new();
1168        let mv = to_idx(7, 7);
1169        board.make_move(mv);
1170        assert!(board.black.get(mv));
1171        assert_eq!(board.side_to_move, Stone::White);
1172
1173        board.undo_move();
1174        assert!(!board.black.get(mv));
1175        assert_eq!(board.side_to_move, Stone::Black);
1176        assert_eq!(board.move_count, 0);
1177    }
1178
1179    /// Zobrist 정합성: make/undo가 incremental XOR로 정확히 원복되는지.
1180    #[test]
1181    fn zobrist_make_undo_roundtrip() {
1182        let mut board = Board::new();
1183        let initial = board.zobrist;
1184        assert_eq!(initial, 0, "empty board zobrist should be 0");
1185
1186        let moves = [112, 113, 97, 98, 127, 128, 200, 14];
1187        let mut keys = vec![initial];
1188        for &m in &moves {
1189            board.make_move(m);
1190            keys.push(board.zobrist);
1191        }
1192        // 모든 중간 키가 unique해야 함 (충돌 없는 상태에서)
1193        let mut sorted = keys.clone();
1194        sorted.sort_unstable();
1195        sorted.dedup();
1196        assert_eq!(sorted.len(), keys.len(), "zobrist sequence collided");
1197
1198        // undo 시 역순으로 정확히 같은 키 복귀
1199        for i in (1..keys.len()).rev() {
1200            board.undo_move();
1201            assert_eq!(
1202                board.zobrist,
1203                keys[i - 1],
1204                "zobrist mismatch after undo step {i}"
1205            );
1206        }
1207        assert_eq!(
1208            board.zobrist, 0,
1209            "zobrist did not return to 0 after full undo"
1210        );
1211    }
1212
1213    /// Pattern4 state cache 정합성: incremental update 결과가 같은 보드를
1214    /// 처음부터 fill_initial_pattern_ids 로 채운 결과와 모든 (cell, dir)에서
1215    /// 동일해야 한다. region recompute가 아닌 진짜 incremental의 핵심 invariant.
1216    #[test]
1217    fn line_pattern_state_make_undo_consistency() {
1218        const DIRS: [(i32, i32); 4] = [(0, 1), (1, 0), (1, 1), (1, -1)];
1219        let moves = [112, 113, 97, 98, 127, 128, 200, 14, 0, 224, 7, 217, 50, 100];
1220
1221        let mut board = Board::new();
1222        let initial_ids = board.line_pattern_ids.clone();
1223
1224        // make_move 각 단계마다 incremental ids == 처음부터 재계산한 ids
1225        for (i, &mv) in moves.iter().enumerate() {
1226            if !board.is_empty(mv) {
1227                continue;
1228            }
1229            board.make_move(mv);
1230
1231            // incremental 후 fresh 보드 재구성 (history replay) + fill_initial 비교
1232            let mut fresh = Board::new();
1233            for &m in &moves[..=i] {
1234                if fresh.is_empty(m) {
1235                    fresh.make_move(m);
1236                }
1237            }
1238            // 또는 더 강하게: 직접 처음부터 재구성한 board의 line_pattern_ids
1239            // == 우리 incremental board의 line_pattern_ids
1240            // fresh 도 incremental 사용하므로 다른 검증: 직접 read_window 계산
1241            for cell in 0..NUM_CELLS {
1242                let row = (cell / BOARD_SIZE) as i32;
1243                let col = (cell % BOARD_SIZE) as i32;
1244                for (dir_idx, &(dr, dc)) in DIRS.iter().enumerate() {
1245                    let w = crate::pattern_table::read_window(
1246                        &board.black,
1247                        &board.white,
1248                        row,
1249                        col,
1250                        dr,
1251                        dc,
1252                    );
1253                    let packed = crate::pattern_table::pack_window(&w);
1254                    let expected = crate::pattern_table::lookup_mapped_id(packed);
1255                    let actual = board.line_pattern_ids[cell][dir_idx];
1256                    assert_eq!(
1257                        actual,
1258                        expected,
1259                        "mismatch at cell {} dir {} after move {} (ply {})",
1260                        cell,
1261                        dir_idx,
1262                        mv,
1263                        i + 1
1264                    );
1265                }
1266            }
1267        }
1268
1269        // undo 모두 → initial ids 복원
1270        for _ in 0..moves.len() {
1271            if !board.history.is_empty() {
1272                board.undo_move();
1273            }
1274        }
1275        assert_eq!(board.move_count, 0);
1276        // initial board 와 같은 ids
1277        for cell in 0..NUM_CELLS {
1278            for d in 0..4 {
1279                assert_eq!(
1280                    board.line_pattern_ids[cell][d], initial_ids[cell][d],
1281                    "after full undo: cell {} dir {} not restored",
1282                    cell, d
1283                );
1284            }
1285        }
1286    }
1287
1288    fn assert_packed_windows_match_full_rebuild(
1289        board: &Board,
1290        state: &BoardSearchState,
1291        operation: usize,
1292    ) {
1293        const DIRS: [(i32, i32); 4] = [(0, 1), (1, 0), (1, 1), (1, -1)];
1294        assert!(state.packed_line_windows_enabled());
1295        assert!(state.is_synchronized(board));
1296        for cell in 0..NUM_CELLS {
1297            let row = (cell / BOARD_SIZE) as i32;
1298            let col = (cell % BOARD_SIZE) as i32;
1299            for (dir_idx, &(dr, dc)) in DIRS.iter().enumerate() {
1300                let window =
1301                    crate::pattern_table::read_window(&board.black, &board.white, row, col, dr, dc);
1302                let expected_packed = crate::pattern_table::pack_window(&window);
1303                let actual_packed = state.packed_line_window(cell, dir_idx).unwrap();
1304                assert_eq!(
1305                    actual_packed, expected_packed,
1306                    "packed mismatch after operation {operation}, cell {cell}, dir {dir_idx}"
1307                );
1308                assert_eq!(
1309                    board.line_pattern_ids[cell][dir_idx],
1310                    crate::pattern_table::lookup_mapped_id(expected_packed),
1311                    "mapped-id mismatch after operation {operation}, cell {cell}, dir {dir_idx}"
1312                );
1313            }
1314        }
1315    }
1316
1317    #[test]
1318    fn packed_line_windows_are_opt_in_for_library_callers_and_toggle_cleanly() {
1319        let mut board = Board::new();
1320        let mut state = BoardSearchState::new();
1321        assert!(!state.packed_line_windows_enabled());
1322        board.make_move(to_idx(7, 7));
1323        state.set_packed_line_windows_enabled(&board, true);
1324        assert_packed_windows_match_full_rebuild(&board, &state, 1);
1325        state.set_packed_line_windows_enabled(&board, false);
1326        assert!(!state.packed_line_windows_enabled());
1327        board.undo_move();
1328        let empty = Board::new();
1329        assert!(board.black == empty.black);
1330        assert!(board.white == empty.white);
1331        assert_eq!(board.side_to_move, empty.side_to_move);
1332        assert_eq!(board.move_count, 0);
1333        assert_eq!(board.line_pattern_ids, empty.line_pattern_ids);
1334    }
1335
1336    #[test]
1337    fn d4_hash_composes_with_existing_sidecars_and_make_undo() {
1338        let sequence = [
1339            to_idx(7, 7),
1340            to_idx(0, 0),
1341            to_idx(14, 14),
1342            to_idx(6, 8),
1343            to_idx(2, 12),
1344            to_idx(11, 3),
1345        ];
1346        let mut board = Board::new();
1347        let mut state = BoardSearchState::new();
1348        state.set_packed_line_windows_enabled(&board, true);
1349        state.set_candidate_frontier_enabled(&board, true);
1350        state.set_d4_hash_enabled(&board, true);
1351
1352        assert!(state.packed_line_windows_enabled());
1353        assert!(state.candidate_frontier_enabled());
1354        assert!(state.d4_hash_enabled());
1355        let root_hashes = state.d4_hashes(&board).expect("D4 hash enabled");
1356        assert_eq!(
1357            root_hashes,
1358            *crate::d4_hash::D4HashState::rebuild(&board).hashes()
1359        );
1360
1361        for &mv in &sequence {
1362            let predicted_hashes = state
1363                .d4_predicted_child_hashes(&board, mv)
1364                .expect("legal child prediction");
1365            let predicted_context = state
1366                .d4_predicted_child_context(&board, mv)
1367                .expect("legal child prediction");
1368            state.make_move(&mut board, mv);
1369            assert_eq!(state.d4_hashes(&board), Some(predicted_hashes));
1370            assert_eq!(state.d4_canonical_context(&board), Some(predicted_context));
1371            assert_eq!(
1372                state.d4_hashes(&board),
1373                Some(*crate::d4_hash::D4HashState::rebuild(&board).hashes())
1374            );
1375            assert_packed_windows_match_full_rebuild(&board, &state, board.move_count);
1376            assert_candidate_frontier_matches_full_rebuild(&board, &state, board.move_count);
1377        }
1378
1379        for operation in (0..sequence.len()).rev() {
1380            state.undo_move(&mut board);
1381            assert_eq!(
1382                state.d4_hashes(&board),
1383                Some(*crate::d4_hash::D4HashState::rebuild(&board).hashes()),
1384                "D4 hashes after undo {operation}"
1385            );
1386        }
1387        assert_eq!(state.d4_hashes(&board), Some(root_hashes));
1388        state.undo_move(&mut board);
1389        assert_eq!(
1390            state.d4_hashes(&board),
1391            Some(root_hashes),
1392            "empty-history undo must not toggle D4 hashes"
1393        );
1394        assert!(state.packed_line_windows_enabled());
1395        assert!(state.candidate_frontier_enabled());
1396        assert!(state.d4_hash_enabled());
1397    }
1398
1399    #[test]
1400    fn d4_hash_rule_change_forces_resync_and_preserves_all_enable_flags() {
1401        let mut board = Board::new();
1402        let mut state = BoardSearchState::new();
1403        state.set_packed_line_windows_enabled(&board, true);
1404        state.set_candidate_frontier_enabled(&board, true);
1405        state.set_d4_hash_enabled(&board, true);
1406        let freestyle = state.d4_hashes(&board).expect("D4 hash enabled");
1407
1408        board.set_rule_set(RuleSet::Caro);
1409        assert!(!state.is_synchronized(&board));
1410        assert_eq!(state.d4_hashes(&board), None);
1411        assert_eq!(state.d4_canonical_context(&board), None);
1412        state.synchronize(&board);
1413        let caro = state.d4_hashes(&board).expect("D4 hash rebuilt");
1414        assert_ne!(caro, freestyle);
1415        assert_eq!(caro, *crate::d4_hash::D4HashState::rebuild(&board).hashes());
1416        assert!(state.packed_line_windows_enabled());
1417        assert!(state.candidate_frontier_enabled());
1418        assert!(state.d4_hash_enabled());
1419
1420        // Exercise the legacy compatibility path: the public flag changes
1421        // effective Freestyle into Standard without changing ordinary
1422        // Zobrist or move count, so the rule component must detect it.
1423        board.set_rule_set(RuleSet::Freestyle);
1424        state.synchronize(&board);
1425        let freestyle_again = state.d4_hashes(&board).unwrap();
1426        board.exact5 = true;
1427        assert_eq!(board.effective_rule_set(), RuleSet::Standard);
1428        assert!(!state.is_synchronized(&board));
1429        state.synchronize(&board);
1430        let standard = state.d4_hashes(&board).unwrap();
1431        assert_ne!(standard, freestyle_again);
1432        assert!(state.packed_line_windows_enabled());
1433        assert!(state.candidate_frontier_enabled());
1434        assert!(state.d4_hash_enabled());
1435    }
1436
1437    /// Release correctness gate: 100k deterministic mixed make/undo
1438    /// operations, lockstep equality with the legacy updater at every step,
1439    /// plus periodic full raw-window rebuild equality.
1440    #[test]
1441    #[ignore = "100k release audit; run explicitly with --release --ignored"]
1442    fn packed_line_windows_100k_make_undo_full_rebuild_equality() {
1443        const OPERATIONS: usize = 100_000;
1444        const FULL_REBUILD_PERIOD: usize = 97;
1445
1446        let mut legacy = Board::new();
1447        let mut packed = Board::new();
1448        let mut state = BoardSearchState::new();
1449        state.set_packed_line_windows_enabled(&packed, true);
1450        let mut rng = 0xDFA2_2026_0725_0001u64;
1451
1452        for operation in 1..=OPERATIONS {
1453            rng ^= rng << 13;
1454            rng ^= rng >> 7;
1455            rng ^= rng << 17;
1456            let should_undo =
1457                !legacy.history.is_empty() && (legacy.move_count >= 180 || (rng & 0b11) == 0);
1458
1459            if should_undo {
1460                legacy.undo_move();
1461                state.undo_move(&mut packed);
1462            } else {
1463                let start = (rng as usize) % NUM_CELLS;
1464                let mv = (0..NUM_CELLS)
1465                    .map(|delta| (start + delta) % NUM_CELLS)
1466                    .find(|&cell| legacy.is_empty(cell))
1467                    .expect("at least one empty cell before make");
1468                legacy.make_move(mv);
1469                state.make_move(&mut packed, mv);
1470            }
1471
1472            assert!(
1473                packed.black == legacy.black,
1474                "black at operation {operation}"
1475            );
1476            assert!(
1477                packed.white == legacy.white,
1478                "white at operation {operation}"
1479            );
1480            assert_eq!(
1481                packed.side_to_move, legacy.side_to_move,
1482                "side at operation {operation}"
1483            );
1484            assert_eq!(
1485                packed.line_pattern_ids, legacy.line_pattern_ids,
1486                "mapped IDs at operation {operation}"
1487            );
1488
1489            if operation % FULL_REBUILD_PERIOD == 0 || operation == OPERATIONS {
1490                assert_packed_windows_match_full_rebuild(&packed, &state, operation);
1491            }
1492        }
1493
1494        while !legacy.history.is_empty() {
1495            legacy.undo_move();
1496            state.undo_move(&mut packed);
1497        }
1498        assert_eq!(packed.line_pattern_ids, legacy.line_pattern_ids);
1499        assert_packed_windows_match_full_rebuild(&packed, &state, OPERATIONS + 1);
1500    }
1501
1502    /// 수 순서 무관 same position → same zobrist.
1503    /// 두 시퀀스가 같은 final position을 만들면 zobrist도 같아야 함.
1504    #[test]
1505    fn zobrist_path_independence() {
1506        let seq1 = [112, 113, 97, 98]; // B(7,7) W(7,8) B(6,7) W(6,8)
1507        let _seq2 = [112, 98, 97, 113]; // 같은 4 돌, 다른 순서 — 단 흑/백 같은 셀에 두는 순서 보존되어야 함
1508
1509        // seq2 invalid (흑이 (7,7)→(6,8)→(6,7)→(7,8) 순서로 두면 백도 다른 셀)
1510        // 정확한 path-equivalent 짝: 두 흑 수 순서 바꾸기
1511        // seq1: B(112), W(113), B(97), W(98)  → black={112,97}, white={113,98}
1512        // seq2: B(97), W(113), B(112), W(98)  → black={97,112}, white={113,98}  같은 final
1513        let seq2 = [97, 113, 112, 98];
1514
1515        let mut b1 = Board::new();
1516        for &m in &seq1 {
1517            b1.make_move(m);
1518        }
1519        let mut b2 = Board::new();
1520        for &m in &seq2 {
1521            b2.make_move(m);
1522        }
1523
1524        assert_eq!(b1.black.lo, b2.black.lo);
1525        assert_eq!(b1.black.hi, b2.black.hi);
1526        assert_eq!(b1.white.lo, b2.white.lo);
1527        assert_eq!(b1.white.hi, b2.white.hi);
1528        assert_eq!(b1.side_to_move, b2.side_to_move);
1529
1530        assert_eq!(
1531            b1.zobrist, b2.zobrist,
1532            "same position should have same zobrist"
1533        );
1534    }
1535
1536    #[test]
1537    fn test_horizontal_win() {
1538        let mut board = Board::new();
1539        // 흑: (7,3) (7,4) (7,5) (7,6) (7,7)
1540        // 백: (8,3) (8,4) (8,5) (8,6)
1541        for i in 0..5 {
1542            board.make_move(to_idx(7, 3 + i)); // 흑
1543            if i < 4 {
1544                board.make_move(to_idx(8, 3 + i)); // 백
1545            }
1546        }
1547        assert_eq!(board.game_result(), GameResult::BlackWin);
1548    }
1549
1550    #[test]
1551    fn test_diagonal_win() {
1552        let mut board = Board::new();
1553        // 흑: (0,0) (1,1) (2,2) (3,3) (4,4) — 대각선
1554        // 백: (0,1) (1,2) (2,3) (3,4)
1555        for i in 0..5 {
1556            board.make_move(to_idx(i, i)); // 흑
1557            if i < 4 {
1558                board.make_move(to_idx(i, i + 1)); // 백
1559            }
1560        }
1561        assert_eq!(board.game_result(), GameResult::BlackWin);
1562    }
1563
1564    #[test]
1565    fn test_no_win_with_four() {
1566        let mut board = Board::new();
1567        for i in 0..4 {
1568            board.make_move(to_idx(7, 3 + i)); // 흑
1569            board.make_move(to_idx(8, 3 + i)); // 백
1570        }
1571        assert_eq!(board.game_result(), GameResult::Ongoing);
1572    }
1573
1574    #[test]
1575    fn freestyle_overline_wins() {
1576        let mut board = Board::new();
1577        let mut last = 0;
1578        for col in 3..=8 {
1579            last = put_stone(&mut board, Stone::Black, 7, col);
1580        }
1581        assert!(board.check_win(last));
1582    }
1583
1584    #[test]
1585    fn standard_overline_does_not_win() {
1586        let mut board = Board::new();
1587        board.set_rule_set(RuleSet::Standard);
1588        let mut last = 0;
1589        for col in 3..=8 {
1590            last = put_stone(&mut board, Stone::Black, 7, col);
1591        }
1592        assert!(!board.check_win(last));
1593    }
1594
1595    #[test]
1596    fn standard_exact_five_wins() {
1597        let mut board = Board::new();
1598        board.set_rule_set(RuleSet::Standard);
1599        let mut last = 0;
1600        for col in 3..=7 {
1601            last = put_stone(&mut board, Stone::Black, 7, col);
1602        }
1603        assert!(board.check_win(last));
1604    }
1605
1606    #[test]
1607    fn caro_blocked_exact_five_does_not_win() {
1608        let mut board = Board::new();
1609        board.set_rule_set(RuleSet::Caro);
1610        put_stone(&mut board, Stone::White, 7, 3);
1611        put_stone(&mut board, Stone::White, 7, 9);
1612        let mut last = 0;
1613        for col in 4..=8 {
1614            last = put_stone(&mut board, Stone::Black, 7, col);
1615        }
1616        assert!(!board.check_win(last));
1617    }
1618
1619    #[test]
1620    fn caro_one_open_exact_five_wins() {
1621        let mut board = Board::new();
1622        board.set_rule_set(RuleSet::Caro);
1623        put_stone(&mut board, Stone::White, 7, 3);
1624        let mut last = 0;
1625        for col in 4..=8 {
1626            last = put_stone(&mut board, Stone::Black, 7, col);
1627        }
1628        assert!(board.check_win(last));
1629    }
1630
1631    #[test]
1632    fn caro_overline_wins_even_when_blocked() {
1633        let mut board = Board::new();
1634        board.set_rule_set(RuleSet::Caro);
1635        put_stone(&mut board, Stone::White, 7, 3);
1636        put_stone(&mut board, Stone::White, 7, 10);
1637        let mut last = 0;
1638        for col in 4..=9 {
1639            last = put_stone(&mut board, Stone::Black, 7, col);
1640        }
1641        assert!(board.check_win(last));
1642    }
1643
1644    #[test]
1645    fn test_candidate_moves_first() {
1646        let board = Board::new();
1647        let moves = board.candidate_moves();
1648        assert_eq!(moves, vec![to_idx(7, 7)]);
1649    }
1650
1651    fn assert_candidate_frontier_matches_full_rebuild(
1652        board: &Board,
1653        state: &BoardSearchState,
1654        operation: usize,
1655    ) {
1656        assert!(state.is_synchronized(board));
1657        let actual = state
1658            .candidate_frontier
1659            .as_ref()
1660            .expect("candidate frontier must be enabled");
1661        let rebuilt = board.rebuild_candidate_frontier();
1662        assert_eq!(
1663            actual.radius2_count, rebuilt.radius2_count,
1664            "radius2 counts at operation {operation}"
1665        );
1666        assert_eq!(
1667            actual.min_source, rebuilt.min_source,
1668            "minimum sources at operation {operation}"
1669        );
1670        assert!(
1671            actual.candidates == rebuilt.candidates,
1672            "candidate bitboard at operation {operation}"
1673        );
1674        assert!(
1675            actual.nonempty_sources == rebuilt.nonempty_sources,
1676            "non-empty source buckets at operation {operation}"
1677        );
1678        for source in 0..NUM_CELLS {
1679            assert!(
1680                actual.by_min_source[source] == rebuilt.by_min_source[source],
1681                "source bucket {source} at operation {operation}"
1682            );
1683        }
1684        assert_eq!(
1685            state.candidate_moves(board),
1686            if board.move_count == 0 {
1687                vec![to_idx(7, 7)]
1688            } else {
1689                board.candidate_moves_legacy()
1690            },
1691            "exact candidate order at operation {operation}"
1692        );
1693    }
1694
1695    #[test]
1696    fn candidate_frontier_is_opt_in_and_preserves_exact_order() {
1697        let sequence = [
1698            to_idx(7, 7),
1699            to_idx(0, 0),
1700            to_idx(14, 14),
1701            to_idx(7, 8),
1702            to_idx(2, 13),
1703            to_idx(12, 1),
1704        ];
1705        let mut board = Board::new();
1706        let mut state = BoardSearchState::new();
1707        assert!(!state.candidate_frontier_enabled());
1708        for &mv in &sequence {
1709            board.make_move(mv);
1710        }
1711        let expected = board.candidate_moves();
1712        state.set_candidate_frontier_enabled(&board, true);
1713        assert!(state.candidate_frontier_enabled());
1714        assert_eq!(state.candidate_moves(&board), expected);
1715        assert_candidate_frontier_matches_full_rebuild(&board, &state, sequence.len());
1716        state.set_candidate_frontier_enabled(&board, false);
1717        assert!(!state.candidate_frontier_enabled());
1718        assert_eq!(state.candidate_moves(&board), expected);
1719    }
1720
1721    /// Release composition correctness gate: 100k deterministic mixed
1722    /// make/undo operations, exact ordered-vector equality against the legacy
1723    /// generator, and periodic complete frontier rebuild equality.
1724    #[test]
1725    #[ignore = "100k release audit; run explicitly with --release --ignored"]
1726    fn candidate_frontier_100k_make_undo_full_rebuild_equality() {
1727        const OPERATIONS: usize = 100_000;
1728        const FULL_REBUILD_PERIOD: usize = 97;
1729
1730        let mut legacy = Board::new();
1731        let mut incremental = Board::new();
1732        let mut state = BoardSearchState::new();
1733        state.set_packed_line_windows_enabled(&incremental, true);
1734        state.set_candidate_frontier_enabled(&incremental, true);
1735        let mut rng = 0xDFA3_2026_0725_0001u64;
1736
1737        for operation in 1..=OPERATIONS {
1738            rng ^= rng << 13;
1739            rng ^= rng >> 7;
1740            rng ^= rng << 17;
1741            let should_undo =
1742                !legacy.history.is_empty() && (legacy.move_count >= 180 || (rng & 0b11) == 0);
1743
1744            if should_undo {
1745                legacy.undo_move();
1746                state.undo_move(&mut incremental);
1747            } else {
1748                let start = (rng as usize) % NUM_CELLS;
1749                let mv = (0..NUM_CELLS)
1750                    .map(|delta| (start + delta) % NUM_CELLS)
1751                    .find(|&cell| legacy.is_empty(cell))
1752                    .expect("at least one empty cell before make");
1753                legacy.make_move(mv);
1754                state.make_move(&mut incremental, mv);
1755            }
1756
1757            assert!(incremental.black == legacy.black);
1758            assert!(incremental.white == legacy.white);
1759            assert_eq!(incremental.side_to_move, legacy.side_to_move);
1760            assert_eq!(
1761                incremental.line_pattern_ids, legacy.line_pattern_ids,
1762                "mapped pattern IDs at operation {operation}"
1763            );
1764            assert_eq!(
1765                state.candidate_moves(&incremental),
1766                legacy.candidate_moves(),
1767                "ordered candidates at operation {operation}"
1768            );
1769
1770            if operation % FULL_REBUILD_PERIOD == 0 || operation == OPERATIONS {
1771                assert_packed_windows_match_full_rebuild(&incremental, &state, operation);
1772                assert_candidate_frontier_matches_full_rebuild(&incremental, &state, operation);
1773            }
1774        }
1775
1776        while !legacy.history.is_empty() {
1777            legacy.undo_move();
1778            state.undo_move(&mut incremental);
1779            assert_eq!(
1780                state.candidate_moves(&incremental),
1781                legacy.candidate_moves()
1782            );
1783        }
1784        assert_packed_windows_match_full_rebuild(&incremental, &state, OPERATIONS + 1);
1785        assert_candidate_frontier_matches_full_rebuild(&incremental, &state, OPERATIONS + 1);
1786    }
1787}