Skip to main content

figrid_board/
board.rs

1/// 오목 보드 엔진
2///
3/// 15×15 보드. Bitboard 표현 (u128 × 2로 225비트 커버).
4/// 흑(선공)과 백(후공) 각각 bitboard 보유.
5
6use std::fmt;
7
8pub const BOARD_SIZE: usize = 15;
9pub const NUM_CELLS: usize = BOARD_SIZE * BOARD_SIZE; // 225
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/// 225비트를 u128 × 2로 표현
27/// lo: 비트 0~127, hi: 비트 128~224
28#[derive(Clone, Copy, PartialEq, Eq)]
29pub struct BitBoard {
30    pub lo: u128,
31    pub hi: u128,
32}
33
34impl BitBoard {
35    pub const EMPTY: Self = Self { lo: 0, hi: 0 };
36
37    #[inline]
38    pub fn get(&self, idx: usize) -> bool {
39        if idx < 128 {
40            (self.lo >> idx) & 1 != 0
41        } else {
42            (self.hi >> (idx - 128)) & 1 != 0
43        }
44    }
45
46    #[inline]
47    pub fn set(&mut self, idx: usize) {
48        if idx < 128 {
49            self.lo |= 1u128 << idx;
50        } else {
51            self.hi |= 1u128 << (idx - 128);
52        }
53    }
54
55    #[inline]
56    pub fn clear(&mut self, idx: usize) {
57        if idx < 128 {
58            self.lo &= !(1u128 << idx);
59        } else {
60            self.hi &= !(1u128 << (idx - 128));
61        }
62    }
63
64    #[inline]
65    pub fn or(&self, other: &BitBoard) -> BitBoard {
66        BitBoard {
67            lo: self.lo | other.lo,
68            hi: self.hi | other.hi,
69        }
70    }
71
72    #[inline]
73    pub fn count_ones(&self) -> u32 {
74        self.lo.count_ones() + self.hi.count_ones()
75    }
76
77    /// Iterate over the indices of set bits, lowest first.
78    /// Enables feature extraction loops to skip empty cells entirely —
79    /// critical when the board is sparse (early/midgame), since a
80    /// stone-driven pass is ~6× cheaper than scanning all 225 cells.
81    #[inline]
82    pub fn iter_ones(&self) -> BitBoardIter {
83        BitBoardIter {
84            lo: self.lo,
85            hi: self.hi,
86        }
87    }
88}
89
90pub struct BitBoardIter {
91    lo: u128,
92    hi: u128,
93}
94
95impl Iterator for BitBoardIter {
96    type Item = usize;
97    #[inline]
98    fn next(&mut self) -> Option<usize> {
99        if self.lo != 0 {
100            let idx = self.lo.trailing_zeros() as usize;
101            self.lo &= self.lo - 1;
102            Some(idx)
103        } else if self.hi != 0 {
104            let idx = 128 + self.hi.trailing_zeros() as usize;
105            self.hi &= self.hi - 1;
106            Some(idx)
107        } else {
108            None
109        }
110    }
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub enum GameResult {
115    BlackWin,
116    WhiteWin,
117    Draw,
118    Ongoing,
119}
120
121/// 착수 = 보드 인덱스 (0~224)
122pub type Move = usize;
123
124#[inline]
125pub fn to_rc(idx: usize) -> (usize, usize) {
126    (idx / BOARD_SIZE, idx % BOARD_SIZE)
127}
128
129#[inline]
130pub fn to_idx(row: usize, col: usize) -> usize {
131    row * BOARD_SIZE + col
132}
133
134/// Zobrist 키 — 보드 상태의 고유 해시.
135/// `(cell, color)` 별로 고정 random u64를 XOR 해서 만든다.
136/// `side_to_move` 도 별도 키로 toggle. make/undo 시 incremental XOR 갱신.
137mod zobrist {
138    use super::{NUM_CELLS, Stone};
139
140    /// 결정적이지만 잘 분산된 splitmix64 변형으로 컴파일 타임 키 생성.
141    const fn splitmix64(seed: u64) -> u64 {
142        let mut x = seed;
143        x = x.wrapping_add(0x9E3779B97F4A7C15);
144        x = (x ^ (x >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
145        x = (x ^ (x >> 27)).wrapping_mul(0x94D049BB133111EB);
146        x ^ (x >> 31)
147    }
148
149    const fn build_keys() -> [[u64; NUM_CELLS]; 2] {
150        let mut out = [[0u64; NUM_CELLS]; 2];
151        let mut color = 0;
152        while color < 2 {
153            let mut cell = 0;
154            while cell < NUM_CELLS {
155                let seed = (color as u64) * 0x9E3779B97F4A7C15 ^ (cell as u64);
156                out[color][cell] = splitmix64(seed);
157                cell += 1;
158            }
159            color += 1;
160        }
161        out
162    }
163
164    pub const STONE_KEYS: [[u64; NUM_CELLS]; 2] = build_keys();
165    pub const SIDE_TO_MOVE_KEY: u64 = splitmix64(0xCAFE_BABE_DEAD_BEEF);
166
167    #[inline]
168    pub const fn key_for(stone: Stone, cell: usize) -> u64 {
169        let color = match stone {
170            Stone::Black => 0,
171            Stone::White => 1,
172        };
173        STONE_KEYS[color][cell]
174    }
175}
176
177pub use zobrist::SIDE_TO_MOVE_KEY as ZOBRIST_SIDE;
178
179#[inline]
180pub const fn zobrist_stone_key(stone: Stone, cell: usize) -> u64 {
181    zobrist::key_for(stone, cell)
182}
183
184/// 4 directional 11-cell line pattern mapped IDs per cell.
185/// Pattern4 mini의 incremental state cache. 값 ∈ [0, PATTERN_NUM_IDS)
186/// (= 0..16385): top 16K mapped + rare bucket 16384. u16에 들어감.
187///
188/// Black-relative storage: 1=black, 2=white로 read_window. side_to_move
189/// 변경에 따라 ID 재계산 안 함 (perspective 변환은 NNUE feature 매핑
190/// 단계에서 처리).
191pub type LinePatternState = Box<[[u16; 4]; NUM_CELLS]>;
192
193#[derive(Clone)]
194pub struct Board {
195    pub black: BitBoard,
196    pub white: BitBoard,
197    pub side_to_move: Stone,
198    pub move_count: usize,
199    pub last_move: Option<Move>,
200    /// 착수 이력 (undo를 위해)
201    pub history: Vec<Move>,
202    /// Zobrist 해시. make_move/undo_move에서 XOR로 incremental 갱신.
203    /// 보드 상태(돌 배치 + side_to_move)의 64-bit fingerprint —
204    /// transposition table 키로 사용.
205    pub zobrist: u64,
206    /// Pattern4 mini state cache. 각 (cell, dir) 11-cell 윈도우의
207    /// canonical pattern ID (black-relative). 빈 보드는 모두 ID 0
208    /// (empty_pattern_id). make/undo가 영향받는 cell의 ID만 lookup으로
209    /// 재계산해 region recompute를 피함. NNUE feature 매핑은 Phase 3에서.
210    pub line_pattern_ids: LinePatternState,
211    /// Standard rule (Gomocup `rule=1`): exactly 5-in-a-row wins. Overlines
212    /// (6+ stones in a line) do NOT win. Default `false` = Freestyle.
213    /// Set after `Board::new()` via direct field mutation when the engine
214    /// receives `INFO rule 1`.
215    pub exact5: bool,
216}
217
218impl Board {
219    pub fn new() -> Self {
220        let mut b = Self {
221            black: BitBoard::EMPTY,
222            white: BitBoard::EMPTY,
223            side_to_move: Stone::Black,
224            move_count: 0,
225            last_move: None,
226            history: Vec::with_capacity(NUM_CELLS),
227            // 빈 보드 + Black to move 의 zobrist 는 0.
228            zobrist: 0,
229            // 정확한 초기값은 fill_initial_pattern_ids 에서 채움 (가장자리는
230            // boundary 포함이라 ID ≠ 0).
231            line_pattern_ids: Box::new([[0u16; 4]; NUM_CELLS]),
232            exact5: false,
233        };
234        b.fill_initial_pattern_ids();
235        b
236    }
237
238    /// 빈 보드 기준 모든 (cell, dir) line pattern mapped ID를 lookup해 채움.
239    /// new() 에서만 호출. 가장자리 cell은 boundary 포함 패턴이라 빈 cell의
240    /// 안쪽 ID(보통 0)과 다른 mapped ID로 채워짐.
241    fn fill_initial_pattern_ids(&mut self) {
242        const DIRS: [(i32, i32); 4] = [(0, 1), (1, 0), (1, 1), (1, -1)];
243        for cell in 0..NUM_CELLS {
244            let row = (cell / BOARD_SIZE) as i32;
245            let col = (cell % BOARD_SIZE) as i32;
246            for (dir_idx, &(dr, dc)) in DIRS.iter().enumerate() {
247                let w = crate::pattern_table::read_window(
248                    &self.black,
249                    &self.white,
250                    row,
251                    col,
252                    dr,
253                    dc,
254                );
255                let packed = crate::pattern_table::pack_window(&w);
256                self.line_pattern_ids[cell][dir_idx] =
257                    crate::pattern_table::lookup_mapped_id(packed);
258            }
259        }
260    }
261
262    /// 해당 칸이 비어있는지
263    #[inline]
264    pub fn is_empty(&self, idx: usize) -> bool {
265        let occupied = self.black.or(&self.white);
266        !occupied.get(idx)
267    }
268
269    /// 현재 턴의 돌 bitboard
270    #[inline]
271    pub fn current_stones(&self) -> &BitBoard {
272        match self.side_to_move {
273            Stone::Black => &self.black,
274            Stone::White => &self.white,
275        }
276    }
277
278    /// 상대 턴의 돌 bitboard
279    #[inline]
280    pub fn opponent_stones(&self) -> &BitBoard {
281        match self.side_to_move {
282            Stone::Black => &self.white,
283            Stone::White => &self.black,
284        }
285    }
286
287    /// 합법 수 목록 생성
288    pub fn legal_moves(&self) -> Vec<Move> {
289        let occupied = self.black.or(&self.white);
290        let mut moves = Vec::with_capacity(NUM_CELLS - self.move_count);
291        for idx in 0..NUM_CELLS {
292            if !occupied.get(idx) {
293                moves.push(idx);
294            }
295        }
296        moves
297    }
298
299    /// 빈 칸 주변(2칸 이내)만 후보로 생성 — 탐색 효율화
300    pub fn candidate_moves(&self) -> Vec<Move> {
301        if self.move_count == 0 {
302            // 첫 수: 천원
303            return vec![to_idx(7, 7)];
304        }
305
306        let occupied = self.black.or(&self.white);
307        let mut seen = [false; NUM_CELLS];
308        let mut moves = Vec::with_capacity(64);
309
310        for idx in 0..NUM_CELLS {
311            if !occupied.get(idx) {
312                continue;
313            }
314            let (r, c) = to_rc(idx);
315            for dr in -2i32..=2 {
316                for dc in -2i32..=2 {
317                    if dr == 0 && dc == 0 {
318                        continue;
319                    }
320                    let nr = r as i32 + dr;
321                    let nc = c as i32 + dc;
322                    if nr < 0 || nr >= BOARD_SIZE as i32 || nc < 0 || nc >= BOARD_SIZE as i32 {
323                        continue;
324                    }
325                    let nidx = to_idx(nr as usize, nc as usize);
326                    if !seen[nidx] && !occupied.get(nidx) {
327                        seen[nidx] = true;
328                        moves.push(nidx);
329                    }
330                }
331            }
332        }
333
334        moves
335    }
336
337    /// 착수
338    pub fn make_move(&mut self, mv: Move) {
339        debug_assert!(mv < NUM_CELLS);
340        debug_assert!(self.is_empty(mv));
341
342        let placed = self.side_to_move;
343        match placed {
344            Stone::Black => self.black.set(mv),
345            Stone::White => self.white.set(mv),
346        }
347        // Zobrist incremental: 새 돌의 (color, cell) 키 XOR + side toggle.
348        self.zobrist ^= zobrist_stone_key(placed, mv);
349        self.zobrist ^= ZOBRIST_SIDE;
350
351        self.history.push(mv);
352        self.last_move = Some(mv);
353        self.move_count += 1;
354        self.side_to_move = placed.opponent();
355
356        // Pattern4 mini state cache: mv 주변 4방향 ±5 cell의 pattern_id 갱신.
357        // black-relative: read_window의 첫 인자 = black. side_to_move 무관.
358        self.update_line_patterns_around(mv);
359    }
360
361    /// 착수 취소
362    pub fn undo_move(&mut self) {
363        if let Some(mv) = self.history.pop() {
364            self.side_to_move = self.side_to_move.opponent();
365            let placed = self.side_to_move;
366            self.move_count -= 1;
367            match placed {
368                Stone::Black => self.black.clear(mv),
369                Stone::White => self.white.clear(mv),
370            }
371            // Zobrist는 XOR이라 같은 키 한 번 더 적용 = 원복.
372            self.zobrist ^= zobrist_stone_key(placed, mv);
373            self.zobrist ^= ZOBRIST_SIDE;
374
375            self.last_move = self.history.last().copied();
376
377            // Pattern4 state cache: mv 주변 4방향 ±5 cell 다시 read+lookup.
378            // mv는 이미 cleared된 상태라 새 윈도우에서 mv = empty.
379            self.update_line_patterns_around(mv);
380        }
381    }
382
383    /// `mv` 주변 4방향 각 ±5 cell (총 ~30~44 cell-dir 쌍)의 11-cell window
384    /// pattern ID를 다시 lookup해 cache 갱신. 보드 경계로 일부 잘림.
385    /// black-relative — read_window의 첫 인자 = black, 둘째 = white.
386    #[inline]
387    fn update_line_patterns_around(&mut self, mv: Move) {
388        const DIRS: [(i32, i32); 4] = [(0, 1), (1, 0), (1, 1), (1, -1)];
389        let row = (mv / BOARD_SIZE) as i32;
390        let col = (mv % BOARD_SIZE) as i32;
391        for (dir_idx, &(dr, dc)) in DIRS.iter().enumerate() {
392            for offset in -5i32..=5 {
393                let r = row + dr * offset;
394                let c = col + dc * offset;
395                if r < 0 || r >= BOARD_SIZE as i32 || c < 0 || c >= BOARD_SIZE as i32 {
396                    continue;
397                }
398                let cell = (r as usize) * BOARD_SIZE + c as usize;
399                let w = crate::pattern_table::read_window(
400                    &self.black,
401                    &self.white,
402                    r,
403                    c,
404                    dr,
405                    dc,
406                );
407                let packed = crate::pattern_table::pack_window(&w);
408                self.line_pattern_ids[cell][dir_idx] =
409                    crate::pattern_table::lookup_mapped_id(packed);
410            }
411        }
412    }
413
414    /// 5목 승리 판정 (마지막 착수 기준)
415    pub fn check_win(&self, mv: Move) -> bool {
416        let (row, col) = to_rc(mv);
417        let stone = if self.black.get(mv) {
418            &self.black
419        } else if self.white.get(mv) {
420            &self.white
421        } else {
422            return false;
423        };
424
425        // 4방향: 가로, 세로, 대각선(\), 역대각선(/)
426        let directions: [(i32, i32); 4] = [(0, 1), (1, 0), (1, 1), (1, -1)];
427
428        for &(dr, dc) in &directions {
429            let mut count = 1;
430
431            // 정방향
432            for step in 1..5 {
433                let nr = row as i32 + dr * step;
434                let nc = col as i32 + dc * step;
435                if nr < 0 || nr >= BOARD_SIZE as i32 || nc < 0 || nc >= BOARD_SIZE as i32 {
436                    break;
437                }
438                if stone.get(to_idx(nr as usize, nc as usize)) {
439                    count += 1;
440                } else {
441                    break;
442                }
443            }
444
445            // 역방향
446            for step in 1..5 {
447                let nr = row as i32 - dr * step;
448                let nc = col as i32 - dc * step;
449                if nr < 0 || nr >= BOARD_SIZE as i32 || nc < 0 || nc >= BOARD_SIZE as i32 {
450                    break;
451                }
452                if stone.get(to_idx(nr as usize, nc as usize)) {
453                    count += 1;
454                } else {
455                    break;
456                }
457            }
458
459            if self.exact5 {
460                // Standard rule (Gomocup rule=1): only exactly 5 wins.
461                // Overlines (count > 5) do NOT count as a win.
462                if count == 5 {
463                    return true;
464                }
465            } else if count >= 5 {
466                // Freestyle (default): 5 or more in a row wins.
467                return true;
468            }
469        }
470
471        false
472    }
473
474    /// 게임 결과 확인
475    pub fn game_result(&self) -> GameResult {
476        if let Some(mv) = self.last_move {
477            if self.check_win(mv) {
478                // 마지막에 둔 사람이 이김 (side_to_move는 이미 넘어간 상태)
479                return match self.side_to_move {
480                    Stone::Black => GameResult::WhiteWin,
481                    Stone::White => GameResult::BlackWin,
482                };
483            }
484        }
485        if self.move_count >= NUM_CELLS {
486            GameResult::Draw
487        } else {
488            GameResult::Ongoing
489        }
490    }
491}
492
493impl fmt::Display for Board {
494    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
495        write!(f, "   ")?;
496        for c in 0..BOARD_SIZE {
497            write!(f, "{:2}", (b'A' + c as u8) as char)?;
498        }
499        writeln!(f)?;
500
501        for r in 0..BOARD_SIZE {
502            write!(f, "{:2} ", r + 1)?;
503            for c in 0..BOARD_SIZE {
504                let idx = to_idx(r, c);
505                if self.black.get(idx) {
506                    write!(f, " X")?;
507                } else if self.white.get(idx) {
508                    write!(f, " O")?;
509                } else {
510                    write!(f, " .")?;
511                }
512            }
513            writeln!(f)?;
514        }
515        Ok(())
516    }
517}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522
523    #[test]
524    fn test_make_undo_move() {
525        let mut board = Board::new();
526        let mv = to_idx(7, 7);
527        board.make_move(mv);
528        assert!(board.black.get(mv));
529        assert_eq!(board.side_to_move, Stone::White);
530
531        board.undo_move();
532        assert!(!board.black.get(mv));
533        assert_eq!(board.side_to_move, Stone::Black);
534        assert_eq!(board.move_count, 0);
535    }
536
537    /// Zobrist 정합성: make/undo가 incremental XOR로 정확히 원복되는지.
538    #[test]
539    fn zobrist_make_undo_roundtrip() {
540        let mut board = Board::new();
541        let initial = board.zobrist;
542        assert_eq!(initial, 0, "empty board zobrist should be 0");
543
544        let moves = [112, 113, 97, 98, 127, 128, 200, 14];
545        let mut keys = vec![initial];
546        for &m in &moves {
547            board.make_move(m);
548            keys.push(board.zobrist);
549        }
550        // 모든 중간 키가 unique해야 함 (충돌 없는 상태에서)
551        let mut sorted = keys.clone();
552        sorted.sort_unstable();
553        sorted.dedup();
554        assert_eq!(sorted.len(), keys.len(), "zobrist sequence collided");
555
556        // undo 시 역순으로 정확히 같은 키 복귀
557        for i in (1..keys.len()).rev() {
558            board.undo_move();
559            assert_eq!(
560                board.zobrist, keys[i - 1],
561                "zobrist mismatch after undo step {i}"
562            );
563        }
564        assert_eq!(board.zobrist, 0, "zobrist did not return to 0 after full undo");
565    }
566
567    /// Pattern4 state cache 정합성: incremental update 결과가 같은 보드를
568    /// 처음부터 fill_initial_pattern_ids 로 채운 결과와 모든 (cell, dir)에서
569    /// 동일해야 한다. region recompute가 아닌 진짜 incremental의 핵심 invariant.
570    #[test]
571    fn line_pattern_state_make_undo_consistency() {
572        const DIRS: [(i32, i32); 4] = [(0, 1), (1, 0), (1, 1), (1, -1)];
573        let moves = [112, 113, 97, 98, 127, 128, 200, 14, 0, 224, 7, 217, 50, 100];
574
575        let mut board = Board::new();
576        let initial_ids = board.line_pattern_ids.clone();
577
578        // make_move 각 단계마다 incremental ids == 처음부터 재계산한 ids
579        for (i, &mv) in moves.iter().enumerate() {
580            if !board.is_empty(mv) {
581                continue;
582            }
583            board.make_move(mv);
584
585            // incremental 후 fresh 보드 재구성 (history replay) + fill_initial 비교
586            let mut fresh = Board::new();
587            for &m in &moves[..=i] {
588                if fresh.is_empty(m) {
589                    fresh.make_move(m);
590                }
591            }
592            // 또는 더 강하게: 직접 처음부터 재구성한 board의 line_pattern_ids
593            // == 우리 incremental board의 line_pattern_ids
594            // fresh 도 incremental 사용하므로 다른 검증: 직접 read_window 계산
595            for cell in 0..NUM_CELLS {
596                let row = (cell / BOARD_SIZE) as i32;
597                let col = (cell % BOARD_SIZE) as i32;
598                for (dir_idx, &(dr, dc)) in DIRS.iter().enumerate() {
599                    let w = crate::pattern_table::read_window(
600                        &board.black,
601                        &board.white,
602                        row,
603                        col,
604                        dr,
605                        dc,
606                    );
607                    let packed = crate::pattern_table::pack_window(&w);
608                    let expected = crate::pattern_table::lookup_mapped_id(packed);
609                    let actual = board.line_pattern_ids[cell][dir_idx];
610                    assert_eq!(
611                        actual, expected,
612                        "mismatch at cell {} dir {} after move {} (ply {})",
613                        cell, dir_idx, mv, i + 1
614                    );
615                }
616            }
617        }
618
619        // undo 모두 → initial ids 복원
620        for _ in 0..moves.len() {
621            if !board.history.is_empty() {
622                board.undo_move();
623            }
624        }
625        assert_eq!(board.move_count, 0);
626        // initial board 와 같은 ids
627        for cell in 0..NUM_CELLS {
628            for d in 0..4 {
629                assert_eq!(
630                    board.line_pattern_ids[cell][d], initial_ids[cell][d],
631                    "after full undo: cell {} dir {} not restored",
632                    cell, d
633                );
634            }
635        }
636    }
637
638    /// 수 순서 무관 same position → same zobrist.
639    /// 두 시퀀스가 같은 final position을 만들면 zobrist도 같아야 함.
640    #[test]
641    fn zobrist_path_independence() {
642        let seq1 = [112, 113, 97, 98]; // B(7,7) W(7,8) B(6,7) W(6,8)
643        let seq2 = [112, 98, 97, 113]; // 같은 4 돌, 다른 순서 — 단 흑/백 같은 셀에 두는 순서 보존되어야 함
644
645        // seq2 invalid (흑이 (7,7)→(6,8)→(6,7)→(7,8) 순서로 두면 백도 다른 셀)
646        // 정확한 path-equivalent 짝: 두 흑 수 순서 바꾸기
647        // seq1: B(112), W(113), B(97), W(98)  → black={112,97}, white={113,98}
648        // seq2: B(97), W(113), B(112), W(98)  → black={97,112}, white={113,98}  같은 final
649        let seq2 = [97, 113, 112, 98];
650
651        let mut b1 = Board::new();
652        for &m in &seq1 { b1.make_move(m); }
653        let mut b2 = Board::new();
654        for &m in &seq2 { b2.make_move(m); }
655
656        assert_eq!(b1.black.lo, b2.black.lo);
657        assert_eq!(b1.black.hi, b2.black.hi);
658        assert_eq!(b1.white.lo, b2.white.lo);
659        assert_eq!(b1.white.hi, b2.white.hi);
660        assert_eq!(b1.side_to_move, b2.side_to_move);
661
662        assert_eq!(b1.zobrist, b2.zobrist, "same position should have same zobrist");
663    }
664
665    #[test]
666    fn test_horizontal_win() {
667        let mut board = Board::new();
668        // 흑: (7,3) (7,4) (7,5) (7,6) (7,7)
669        // 백: (8,3) (8,4) (8,5) (8,6)
670        for i in 0..5 {
671            board.make_move(to_idx(7, 3 + i)); // 흑
672            if i < 4 {
673                board.make_move(to_idx(8, 3 + i)); // 백
674            }
675        }
676        assert_eq!(board.game_result(), GameResult::BlackWin);
677    }
678
679    #[test]
680    fn test_diagonal_win() {
681        let mut board = Board::new();
682        // 흑: (0,0) (1,1) (2,2) (3,3) (4,4) — 대각선
683        // 백: (0,1) (1,2) (2,3) (3,4)
684        for i in 0..5 {
685            board.make_move(to_idx(i, i)); // 흑
686            if i < 4 {
687                board.make_move(to_idx(i, i + 1)); // 백
688            }
689        }
690        assert_eq!(board.game_result(), GameResult::BlackWin);
691    }
692
693    #[test]
694    fn test_no_win_with_four() {
695        let mut board = Board::new();
696        for i in 0..4 {
697            board.make_move(to_idx(7, 3 + i)); // 흑
698            board.make_move(to_idx(8, 3 + i)); // 백
699        }
700        assert_eq!(board.game_result(), GameResult::Ongoing);
701    }
702
703    #[test]
704    fn test_candidate_moves_first() {
705        let board = Board::new();
706        let moves = board.candidate_moves();
707        assert_eq!(moves, vec![to_idx(7, 7)]);
708    }
709}