mirror of
https://github.com/LHY0125/Gobang-Game.git
synced 2026-06-29 00:45:55 +08:00
1557 lines
47 KiB
Markdown
1557 lines
47 KiB
Markdown
# Gobang AI 升级实施计划
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** 将 Alpha-Beta AI 升级为专业级:迭代加深、置换表、组合棋形、Killer启发、VCF/VCT、开局库。
|
||
|
||
**Architecture:** 6 个新模块逐步叠加到现有 AI 框架上,Board 先行支持 Zobrist 哈希,置换表+killer 增强搜索,组合棋形+位置权重改善评估,VCF/VCT 独立搜索,开局库预处理。最后重写 search.rs 串联全部组件。
|
||
|
||
**Tech Stack:** Rust, fxhash (快速哈希), rand (开局随机变招)
|
||
|
||
---
|
||
|
||
## 文件变更总览
|
||
|
||
| 文件 | 操作 | 内容 |
|
||
|------|------|------|
|
||
| `core/Cargo.toml` | 改 | +rand, +fxhash |
|
||
| `core/src/types.rs` | 改 | ZobristHash 类型 |
|
||
| `core/src/board.rs` | 改 | zobrist_hash 字段, place/undo 增量更新, 测试 |
|
||
| `core/src/ai/trans_table.rs` | 新建 | TTEntry, TransTable, Zobrist 初始化, 测试 |
|
||
| `core/src/ai/killer.rs` | 新建 | KillerTable, 2-slot/depth, 测试 |
|
||
| `core/src/ai/evaluate.rs` | 重写 | 组合棋形 + 位置权重, 测试 |
|
||
| `core/src/ai/opening.rs` | 新建 | OpeningBook, 50 定式 load, lookup, 测试 |
|
||
| `core/src/ai/vcf.rs` | 新建 | vcf_search/vct_search, 测试 |
|
||
| `core/src/ai/search.rs` | 重写 | 迭代加深 + TT + killer + evaluate + opening |
|
||
| `core/src/ai/mod.rs` | 改 | 公开新模块 |
|
||
| `gui/src/commands.rs` | 改 | new_game 适配 |
|
||
|
||
---
|
||
|
||
### Task 1: Board Zobrist 哈希增量更新
|
||
|
||
**Files:**
|
||
- Modify: `core/src/types.rs`
|
||
- Modify: `core/src/board.rs`
|
||
|
||
- [ ] **Step 1: 添加 ZobristHash 类型和全局表**
|
||
|
||
在 `core/src/types.rs` 末尾添加:
|
||
|
||
```rust
|
||
/// Zobrist 哈希值
|
||
pub type ZobristHash = u64;
|
||
|
||
/// 全局 Zobrist 随机表(pub 供 ai 模块使用)
|
||
pub fn init_zobrist_table(board_size: usize) -> Vec<Vec<[ZobristHash; 2]>> {
|
||
use std::collections::hash_map::RandomState;
|
||
use std::hash::BuildHasher;
|
||
let rng = RandomState::new();
|
||
let mut table = Vec::with_capacity(board_size);
|
||
for x in 0..board_size {
|
||
let mut row = Vec::with_capacity(board_size);
|
||
for y in 0..board_size {
|
||
row.push([rng.hash_one((x, y, 0)), rng.hash_one((x, y, 1))]);
|
||
}
|
||
table.push(row);
|
||
}
|
||
table
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 在 Board struct 添加 hash 字段和方法**
|
||
|
||
在 `core/src/board.rs` 的 `Board` struct 中添加 `hash` 字段(放在 `current_turn` 之后):
|
||
|
||
```rust
|
||
pub zobrist_hash: ZobristHash,
|
||
```
|
||
|
||
修改 `Board::new` 初始化:
|
||
|
||
```rust
|
||
zobrist_hash: 0,
|
||
```
|
||
|
||
添加方法:
|
||
|
||
```rust
|
||
/// 获取当前 Zobrist 哈希
|
||
pub fn hash(&self) -> ZobristHash {
|
||
self.zobrist_hash
|
||
}
|
||
```
|
||
|
||
修改 `place` 方法,在 `new_board.cells[pos.x][pos.y] = ...` 之后、history push 之前添加:
|
||
|
||
```rust
|
||
let color_idx = match color { Color::Black => 0, Color::White => 1 };
|
||
let zobrist = crate::types::init_zobrist_table(self.size);
|
||
new_board.zobrist_hash ^= zobrist[pos.x][pos.y][color_idx];
|
||
```
|
||
|
||
修改 `undo` 方法,在 `new_board.cells[...] = CellState::Empty` 之后添加:
|
||
|
||
```rust
|
||
let last_color_idx = match last_move.color { Color::Black => 0, Color::White => 1 };
|
||
let zobrist = crate::types::init_zobrist_table(self.size);
|
||
new_board.zobrist_hash ^= zobrist[last_move.position.x][last_move.position.y][last_color_idx];
|
||
```
|
||
|
||
- [ ] **Step 3: 写 Zobrist 哈希测试**
|
||
|
||
在 `board.rs` 测试模块中添加:
|
||
|
||
```rust
|
||
#[test]
|
||
fn test_zobrist_hash_changes_on_place() {
|
||
let board = Board::new(15);
|
||
let h1 = board.hash();
|
||
let board2 = board.place(Position::new(7, 7), Color::Black).unwrap();
|
||
assert_ne!(h1, board2.hash());
|
||
}
|
||
|
||
#[test]
|
||
fn test_zobrist_hash_restores_on_undo() {
|
||
let board = Board::new(15);
|
||
let board = board.place(Position::new(7, 7), Color::Black).unwrap();
|
||
let h1 = board.hash();
|
||
let board = board.place(Position::new(7, 8), Color::White).unwrap();
|
||
assert_ne!(h1, board.hash());
|
||
let board = board.undo().unwrap();
|
||
assert_eq!(h1, board.hash());
|
||
}
|
||
|
||
#[test]
|
||
fn test_zobrist_hash_symmetry() {
|
||
// (7,7)黑棋 和 (7,8)黑棋 的哈希不同
|
||
let board = Board::new(15);
|
||
let b1 = board.place(Position::new(7, 7), Color::Black).unwrap();
|
||
let b2 = board.place(Position::new(7, 8), Color::Black).unwrap();
|
||
assert_ne!(b1.hash(), b2.hash());
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 运行测试并提交**
|
||
|
||
```bash
|
||
cargo test -p gobang-core
|
||
git add core/src/types.rs core/src/board.rs
|
||
git commit -m "feat: Board 新增 Zobrist 哈希增量更新 + 测试"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: 置换表 TransTable
|
||
|
||
**Files:**
|
||
- Create: `core/src/ai/trans_table.rs`
|
||
- Modify: `core/src/ai/mod.rs`
|
||
- Modify: `core/Cargo.toml` (+fxhash)
|
||
|
||
- [ ] **Step 1: 添加依赖**
|
||
|
||
在 `core/Cargo.toml` 添加:
|
||
|
||
```toml
|
||
fxhash = "0.2"
|
||
```
|
||
|
||
- [ ] **Step 2: 创建 trans_table.rs**
|
||
|
||
```rust
|
||
use crate::types::{Position, ZobristHash};
|
||
|
||
const TT_SIZE: usize = 1 << 20; // 约 100 万条目
|
||
const TT_MASK: usize = TT_SIZE - 1;
|
||
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum BoundType {
|
||
Exact,
|
||
LowerBound,
|
||
UpperBound,
|
||
}
|
||
|
||
#[derive(Debug, Clone)]
|
||
pub struct TTEntry {
|
||
pub hash: ZobristHash, // 完整 64 位哈希(防冲突)
|
||
pub depth: u8,
|
||
pub score: i32,
|
||
pub bound: BoundType,
|
||
pub best_move: Option<Position>,
|
||
}
|
||
|
||
pub struct TransTable {
|
||
entries: Vec<Option<TTEntry>>,
|
||
}
|
||
|
||
impl TransTable {
|
||
pub fn new() -> Self {
|
||
Self {
|
||
entries: vec![None; TT_SIZE],
|
||
}
|
||
}
|
||
|
||
pub fn probe(&self, hash: ZobristHash, depth: u8) -> Option<&TTEntry> {
|
||
let idx = (hash as usize) & TT_MASK;
|
||
self.entries[idx].as_ref().filter(|e| e.hash == hash && e.depth >= depth)
|
||
}
|
||
|
||
pub fn store(&mut self, hash: ZobristHash, depth: u8, score: i32, bound: BoundType, best_move: Option<Position>) {
|
||
let idx = (hash as usize) & TT_MASK;
|
||
// 深度优先替换
|
||
let should_replace = match &self.entries[idx] {
|
||
None => true,
|
||
Some(old) => depth >= old.depth,
|
||
};
|
||
if should_replace {
|
||
self.entries[idx] = Some(TTEntry { hash, depth, score, bound, best_move });
|
||
}
|
||
}
|
||
|
||
pub fn clear(&mut self) {
|
||
self.entries.fill(None);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn test_store_and_probe() {
|
||
let mut tt = TransTable::new();
|
||
tt.store(12345, 3, 100, BoundType::Exact, Some(Position::new(7, 7)));
|
||
let entry = tt.probe(12345, 2).unwrap();
|
||
assert_eq!(entry.score, 100);
|
||
assert!(entry.best_move.is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn test_probe_rejects_lower_depth() {
|
||
let mut tt = TransTable::new();
|
||
tt.store(42, 5, 200, BoundType::Exact, None);
|
||
// depth 5 满足 depth >= 4 的查询
|
||
assert!(tt.probe(42, 4).is_some());
|
||
// depth 5 不满足 depth >= 6
|
||
assert!(tt.probe(42, 6).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn test_hash_collision_prevention() {
|
||
let mut tt = TransTable::new();
|
||
tt.store(100, 3, 50, BoundType::Exact, None);
|
||
// 不同哈希不应命中
|
||
assert!(tt.probe(200, 1).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn test_depth_priority_replacement() {
|
||
let mut tt = TransTable::new();
|
||
tt.store(999, 2, 10, BoundType::Exact, None);
|
||
tt.store(999, 5, 99, BoundType::Exact, None);
|
||
let entry = tt.probe(999, 3).unwrap();
|
||
assert_eq!(entry.score, 99);
|
||
}
|
||
|
||
#[test]
|
||
fn test_clear() {
|
||
let mut tt = TransTable::new();
|
||
tt.store(1, 1, 1, BoundType::Exact, None);
|
||
tt.clear();
|
||
assert!(tt.probe(1, 0).is_none());
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: 在 ai/mod.rs 注册模块**
|
||
|
||
```rust
|
||
pub mod trans_table;
|
||
```
|
||
|
||
- [ ] **Step 4: 验证编译和测试**
|
||
|
||
```bash
|
||
cargo test -p gobang-core trans_table
|
||
```
|
||
|
||
Expected: 5 个测试通过。
|
||
|
||
- [ ] **Step 5: 提交**
|
||
|
||
```bash
|
||
git add core/Cargo.toml core/src/ai/trans_table.rs core/src/ai/mod.rs
|
||
git commit -m "feat: 置换表实现 — Zobrist 索引 + depth 优先替换 + 5 测试"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Killer Move 表
|
||
|
||
**Files:**
|
||
- Create: `core/src/ai/killer.rs`
|
||
- Modify: `core/src/ai/mod.rs`
|
||
|
||
- [ ] **Step 1: 创建 killer.rs**
|
||
|
||
```rust
|
||
use crate::types::Position;
|
||
|
||
const MAX_DEPTH: usize = 32;
|
||
const SLOTS_PER_DEPTH: usize = 2;
|
||
|
||
pub struct KillerTable {
|
||
moves: [[Option<Position>; SLOTS_PER_DEPTH]; MAX_DEPTH],
|
||
}
|
||
|
||
impl KillerTable {
|
||
pub fn new() -> Self {
|
||
Self {
|
||
moves: [[None; SLOTS_PER_DEPTH]; MAX_DEPTH],
|
||
}
|
||
}
|
||
|
||
/// 记录一个产生剪枝的走法
|
||
pub fn record(&mut self, depth: usize, pos: Position) {
|
||
if depth >= MAX_DEPTH {
|
||
return;
|
||
}
|
||
let slot0 = &self.moves[depth][0];
|
||
if slot0.as_ref() != Some(&pos) {
|
||
self.moves[depth][1] = *slot0;
|
||
self.moves[depth][0] = Some(pos);
|
||
}
|
||
}
|
||
|
||
/// 获取该深度的 killer moves (按优先级)
|
||
pub fn get(&self, depth: usize) -> [Option<Position>; SLOTS_PER_DEPTH] {
|
||
if depth >= MAX_DEPTH {
|
||
return [None, None];
|
||
}
|
||
self.moves[depth]
|
||
}
|
||
|
||
pub fn clear(&mut self) {
|
||
self.moves = [[None; SLOTS_PER_DEPTH]; MAX_DEPTH];
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn test_record_and_get() {
|
||
let mut kt = KillerTable::new();
|
||
let pos = Position::new(7, 7);
|
||
kt.record(3, pos);
|
||
let got = kt.get(3);
|
||
assert_eq!(got[0], Some(pos));
|
||
}
|
||
|
||
#[test]
|
||
fn test_two_slots_fifo() {
|
||
let mut kt = KillerTable::new();
|
||
kt.record(1, Position::new(7, 7));
|
||
kt.record(1, Position::new(8, 8));
|
||
kt.record(1, Position::new(9, 9));
|
||
let got = kt.get(1);
|
||
// slot0 = (9,9) (latest), slot1 = (8,8) (previous)
|
||
assert_eq!(got[0], Some(Position::new(9, 9)));
|
||
assert_eq!(got[1], Some(Position::new(8, 8)));
|
||
}
|
||
|
||
#[test]
|
||
fn test_duplicate_not_reinserted() {
|
||
let mut kt = KillerTable::new();
|
||
kt.record(2, Position::new(7, 7));
|
||
kt.record(2, Position::new(7, 7)); // duplicate
|
||
let got = kt.get(2);
|
||
assert_eq!(got[0], Some(Position::new(7, 7)));
|
||
assert_eq!(got[1], None); // 不会把同一个 move 放到 slot1
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 在 ai/mod.rs 注册**
|
||
|
||
```rust
|
||
pub mod killer;
|
||
```
|
||
|
||
- [ ] **Step 3: 验证编译和测试**
|
||
|
||
```bash
|
||
cargo test -p gobang-core killer
|
||
```
|
||
|
||
Expected: 3 个测试通过。
|
||
|
||
- [ ] **Step 4: 提交**
|
||
|
||
```bash
|
||
git add core/src/ai/killer.rs core/src/ai/mod.rs
|
||
git commit -m "feat: Killer move 表 — 2-slot/depth + 3 测试"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: 组合棋形评估 + 位置权重
|
||
|
||
**Files:**
|
||
- Modify: `core/src/ai/evaluate.rs` (重写)
|
||
|
||
- [ ] **Step 1: 重写 evaluate.rs**
|
||
|
||
```rust
|
||
use crate::board::Board;
|
||
use crate::types::{CellState, Color, Position};
|
||
|
||
const FIVE: f64 = 100000.0;
|
||
const OPEN_FOUR: f64 = 10000.0;
|
||
const RUSH_FOUR: f64 = 5000.0;
|
||
const OPEN_THREE: f64 = 1000.0;
|
||
const SLEEP_THREE: f64 = 500.0;
|
||
const OPEN_TWO: f64 = 100.0;
|
||
const SLEEP_TWO: f64 = 50.0;
|
||
const OPEN_ONE: f64 = 10.0;
|
||
|
||
// 组合加分
|
||
const COMBO_THREE_THREE: f64 = 5000.0; // 双活三交叉
|
||
const COMBO_THREE_FOUR: f64 = 10000.0; // 活三+冲四
|
||
const COMBO_FOUR_FOUR: f64 = 8000.0; // 双冲四
|
||
const COMBO_THREE_TWO: f64 = 500.0; // 活三+活二
|
||
|
||
// 位置权重最大加分
|
||
const POSITION_MAX_BONUS: f64 = 50.0;
|
||
|
||
/// 评估棋盘对 player 的得分
|
||
pub fn evaluate_board(board: &Board, player: Color) -> f64 {
|
||
let p_score = evaluate_player(board, player);
|
||
let o_score = evaluate_player(board, player.opponent());
|
||
p_score - o_score
|
||
}
|
||
|
||
fn evaluate_player(board: &Board, color: Color) -> f64 {
|
||
let directions: [(isize, isize); 4] = [(0, 1), (1, 0), (1, 1), (1, -1)];
|
||
let mut total = 0.0f64;
|
||
let size = board.size;
|
||
let center = (size as f64 - 1.0) / 2.0;
|
||
|
||
for x in 0..size {
|
||
for y in 0..size {
|
||
if board.get(Position::new(x, y)) != CellState::Occupied(color) {
|
||
continue;
|
||
}
|
||
|
||
let mut patterns = Vec::with_capacity(4);
|
||
for &(dx, dy) in &directions {
|
||
let (count, start_open, end_open) =
|
||
scan_pattern(board, Position::new(x, y), color, dx, dy);
|
||
if count > 0 {
|
||
let open_count = start_open as u32 + end_open as u32;
|
||
patterns.push((count, open_count));
|
||
total += score_pattern(count, open_count);
|
||
}
|
||
}
|
||
|
||
// 组合棋形检测
|
||
if patterns.len() >= 2 {
|
||
for i in 0..patterns.len() {
|
||
for j in (i + 1)..patterns.len() {
|
||
let (c1, o1) = patterns[i];
|
||
let (c2, o2) = patterns[j];
|
||
// 活三 + 活三
|
||
if c1 >= 3 && o1 == 2 && c2 >= 3 && o2 == 2 {
|
||
total += COMBO_THREE_THREE;
|
||
}
|
||
// 活三 + 冲四
|
||
if (c1 >= 3 && o1 == 2 && c2 == 4 && o2 == 1)
|
||
|| (c1 == 4 && o1 == 1 && c2 >= 3 && o2 == 2)
|
||
{
|
||
total += COMBO_THREE_FOUR;
|
||
}
|
||
// 双冲四
|
||
if c1 == 4 && o1 == 1 && c2 == 4 && o2 == 1 {
|
||
total += COMBO_FOUR_FOUR;
|
||
}
|
||
// 活三 + 活二
|
||
if (c1 >= 3 && o1 == 2 && c2 == 2 && o2 == 2)
|
||
|| (c1 == 2 && o1 == 2 && c2 >= 3 && o2 == 2)
|
||
{
|
||
total += COMBO_THREE_TWO;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 位置权重(仅对每个棋子加一次)
|
||
let dx = x as f64 - center;
|
||
let dy = y as f64 - center;
|
||
let dist = (dx * dx + dy * dy).sqrt();
|
||
let max_dist = center;
|
||
let position_bonus = POSITION_MAX_BONUS * (1.0 - dist / max_dist).max(0.0);
|
||
total += position_bonus;
|
||
}
|
||
}
|
||
|
||
total
|
||
}
|
||
|
||
fn scan_pattern(
|
||
board: &Board, pos: Position, color: Color, dx: isize, dy: isize,
|
||
) -> (u32, bool, bool) {
|
||
let mut count = 1u32;
|
||
|
||
// 正方向
|
||
let mut nx = pos.x as isize + dx;
|
||
let mut ny = pos.y as isize + dy;
|
||
while in_bounds(board, nx, ny)
|
||
&& board.get(Position::new(nx as usize, ny as usize)) == CellState::Occupied(color)
|
||
{
|
||
count += 1;
|
||
nx += dx;
|
||
ny += dy;
|
||
}
|
||
let end_open = in_bounds(board, nx, ny)
|
||
&& board.get(Position::new(nx as usize, ny as usize)) == CellState::Empty;
|
||
|
||
// 反方向
|
||
let sx = pos.x as isize - dx;
|
||
let sy = pos.y as isize - dy;
|
||
let start_open = in_bounds(board, sx, sy)
|
||
&& board.get(Position::new(sx as usize, sy as usize)) == CellState::Empty;
|
||
|
||
// 避免重复计数
|
||
if in_bounds(board, sx, sy)
|
||
&& board.get(Position::new(sx as usize, sy as usize)) == CellState::Occupied(color)
|
||
{
|
||
return (0, false, false);
|
||
}
|
||
|
||
(count, start_open, end_open)
|
||
}
|
||
|
||
fn score_pattern(count: u32, open_count: u32) -> f64 {
|
||
match (count, open_count) {
|
||
(5, _) => FIVE,
|
||
(4, 2) => OPEN_FOUR,
|
||
(4, 1) => RUSH_FOUR,
|
||
(3, 2) => OPEN_THREE,
|
||
(3, 1) => SLEEP_THREE,
|
||
(2, 2) => OPEN_TWO,
|
||
(2, 1) => SLEEP_TWO,
|
||
(1, 2) => OPEN_ONE,
|
||
_ => 0.0,
|
||
}
|
||
}
|
||
|
||
fn in_bounds(board: &Board, x: isize, y: isize) -> bool {
|
||
x >= 0 && y >= 0 && (x as usize) < board.size && (y as usize) < board.size
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::board::Board;
|
||
use crate::types::{Color, Position};
|
||
|
||
#[test]
|
||
fn test_evaluate_empty_board() {
|
||
let board = Board::new(15);
|
||
assert_eq!(evaluate_board(&board, Color::Black), 0.0);
|
||
}
|
||
|
||
#[test]
|
||
fn test_five_in_a_row() {
|
||
let board = Board::new(15);
|
||
let mut board = board;
|
||
for y in 5..10 {
|
||
board = board.place(Position::new(7, y), Color::Black).unwrap();
|
||
}
|
||
let score = evaluate_board(&board, Color::Black);
|
||
assert!(score > 10000.0);
|
||
}
|
||
|
||
#[test]
|
||
fn test_center_position_worth_more() {
|
||
let board = Board::new(15);
|
||
let b_center = board.place(Position::new(7, 7), Color::Black).unwrap();
|
||
let b_edge = board.place(Position::new(0, 0), Color::Black).unwrap();
|
||
let score_center = evaluate_board(&b_center, Color::Black);
|
||
let score_edge = evaluate_board(&b_edge, Color::Black);
|
||
assert!(score_center > score_edge, "center should score higher");
|
||
}
|
||
|
||
#[test]
|
||
fn test_combo_three_three_detected() {
|
||
// 构建交叉活三局面
|
||
let board = Board::new(15);
|
||
let mut board = board;
|
||
// 水平方向活三: (7,5)(7,6)(7,7)
|
||
board = board.place(Position::new(7, 5), Color::Black).unwrap();
|
||
board = board.place(Position::new(7, 6), Color::Black).unwrap();
|
||
board = board.place(Position::new(7, 7), Color::Black).unwrap();
|
||
// 垂直方向活三: (5,7)(6,7) -> 与(7,7)交叉
|
||
board = board.place(Position::new(5, 7), Color::Black).unwrap();
|
||
board = board.place(Position::new(6, 7), Color::Black).unwrap();
|
||
let score = evaluate_board(&board, Color::Black);
|
||
assert!(score > COMBO_THREE_THREE * 0.5);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 验证编译和测试**
|
||
|
||
```bash
|
||
cargo test -p gobang-core ai::evaluate
|
||
```
|
||
|
||
Expected: 4 个新测试 + 2 个已有全通过。
|
||
|
||
- [ ] **Step 3: 提交**
|
||
|
||
```bash
|
||
git add core/src/ai/evaluate.rs
|
||
git commit -m "feat: 组合棋形评估 + 位置权重 — 交叉活三/双冲四检测 + 4 测试"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: 开局库
|
||
|
||
**Files:**
|
||
- Create: `core/src/ai/opening.rs`
|
||
- Modify: `core/src/ai/mod.rs`
|
||
- Modify: `core/Cargo.toml` (+rand)
|
||
|
||
- [ ] **Step 1: 添加依赖**
|
||
|
||
在 `core/Cargo.toml` 添加:
|
||
|
||
```toml
|
||
rand = "0.8"
|
||
```
|
||
|
||
- [ ] **Step 2: 创建 opening.rs**
|
||
|
||
```rust
|
||
use crate::board::Board;
|
||
use crate::types::{Color, Position, ZobristHash};
|
||
use rand::seq::SliceRandom;
|
||
use std::collections::HashMap;
|
||
|
||
pub struct OpeningBook {
|
||
positions: HashMap<ZobristHash, Vec<Position>>,
|
||
}
|
||
|
||
impl OpeningBook {
|
||
pub fn new() -> Self {
|
||
let mut book = Self { positions: HashMap::new() };
|
||
book.load();
|
||
book
|
||
}
|
||
|
||
/// 加载 50 个标准五子棋开局定式
|
||
fn load(&mut self) {
|
||
// 开局定式格式: (x, y) 序列,适用于 15x15 棋盘,黑先
|
||
let openings: Vec<Vec<(usize, usize)>> = vec![
|
||
// 花月开局
|
||
vec![(7, 7), (7, 8), (6, 7), (6, 6), (8, 6)],
|
||
vec![(7, 7), (7, 8), (6, 7), (8, 8), (5, 7)],
|
||
// 浦月开局
|
||
vec![(7, 7), (8, 7), (7, 6), (6, 6), (8, 5)],
|
||
vec![(7, 7), (8, 7), (7, 6), (7, 8), (6, 5)],
|
||
// 云月开局
|
||
vec![(7, 7), (6, 6), (7, 6), (8, 8), (6, 5)],
|
||
vec![(7, 7), (6, 6), (7, 6), (8, 6), (5, 7)],
|
||
// 雨月开局
|
||
vec![(7, 7), (6, 8), (6, 7), (8, 7), (5, 7)],
|
||
vec![(7, 7), (6, 8), (6, 7), (7, 8), (5, 6)],
|
||
// 溪月开局
|
||
vec![(7, 7), (8, 6), (7, 6), (6, 8), (8, 5)],
|
||
vec![(7, 7), (8, 6), (7, 6), (9, 6), (6, 7)],
|
||
// 金星开局
|
||
vec![(7, 7), (7, 6), (8, 8), (6, 7), (8, 7)],
|
||
vec![(7, 7), (7, 6), (8, 8), (6, 8), (5, 8)],
|
||
// 水月开局
|
||
vec![(7, 7), (8, 8), (7, 6), (6, 7), (8, 6)],
|
||
vec![(7, 7), (8, 8), (7, 6), (7, 8), (8, 7)],
|
||
// 新月开局
|
||
vec![(7, 7), (6, 8), (8, 6), (5, 7), (8, 8)],
|
||
vec![(7, 7), (6, 8), (8, 6), (6, 6), (9, 5)],
|
||
// 疏星 (常见平衡开局)
|
||
vec![(7, 7), (8, 7), (7, 8), (6, 6), (9, 7)],
|
||
vec![(7, 7), (8, 7), (7, 8), (6, 7), (9, 6)],
|
||
vec![(7, 7), (8, 7), (7, 8), (7, 6), (9, 8)],
|
||
vec![(7, 7), (8, 7), (7, 8), (8, 6), (6, 8)],
|
||
// 瑞星开局
|
||
vec![(7, 7), (8, 6), (6, 8), (5, 7), (8, 8)],
|
||
vec![(7, 7), (8, 6), (6, 8), (9, 7), (6, 6)],
|
||
// 山月开局
|
||
vec![(7, 7), (6, 6), (8, 6), (7, 8), (5, 5)],
|
||
vec![(7, 7), (6, 6), (8, 6), (9, 5), (7, 5)],
|
||
// 岚月开局
|
||
vec![(7, 7), (8, 8), (6, 8), (7, 6), (9, 9)],
|
||
vec![(7, 7), (8, 8), (6, 8), (5, 7), (8, 9)],
|
||
// 银月开局
|
||
vec![(7, 7), (6, 6), (7, 8), (8, 7), (5, 5)],
|
||
vec![(7, 7), (6, 6), (7, 8), (8, 6), (5, 7)],
|
||
// 恒星开局
|
||
vec![(7, 7), (6, 8), (8, 7), (7, 6), (5, 9)],
|
||
vec![(7, 7), (6, 8), (8, 7), (5, 6), (9, 6)],
|
||
// 寒星开局
|
||
vec![(7, 7), (7, 6), (6, 8), (8, 7), (5, 8)],
|
||
vec![(7, 7), (7, 6), (6, 8), (5, 8), (8, 5)],
|
||
// 明星开局
|
||
vec![(7, 7), (6, 7), (8, 7), (6, 6), (8, 8)],
|
||
vec![(7, 7), (6, 7), (8, 7), (5, 7), (9, 7)],
|
||
// 斜月开局
|
||
vec![(7, 7), (8, 6), (7, 6), (9, 5), (6, 8)],
|
||
vec![(7, 7), (8, 6), (7, 6), (6, 7), (8, 5)],
|
||
// 名月开局
|
||
vec![(7, 7), (7, 8), (6, 6), (8, 7), (8, 9)],
|
||
vec![(7, 7), (7, 8), (6, 6), (5, 7), (6, 8)],
|
||
// 彗星开局
|
||
vec![(7, 7), (8, 8), (7, 8), (6, 7), (9, 9)],
|
||
vec![(7, 7), (8, 8), (7, 8), (9, 7), (6, 9)],
|
||
// 残月开局
|
||
vec![(7, 7), (6, 7), (8, 6), (7, 8), (5, 7)],
|
||
vec![(7, 7), (6, 7), (8, 6), (9, 5), (7, 5)],
|
||
// 长星开局
|
||
vec![(7, 7), (8, 7), (6, 7), (9, 7), (5, 7)],
|
||
vec![(7, 7), (8, 7), (6, 7), (7, 8), (7, 6)],
|
||
// 峡月开局
|
||
vec![(7, 7), (7, 8), (8, 7), (6, 6), (6, 9)],
|
||
vec![(7, 7), (7, 8), (8, 7), (8, 9), (9, 8)],
|
||
// 溪月变招
|
||
vec![(7, 7), (8, 6), (7, 5), (6, 7), (8, 8)],
|
||
vec![(7, 7), (8, 6), (7, 5), (7, 8), (9, 7)],
|
||
// 均衡开局补充
|
||
vec![(7, 7), (7, 8), (8, 7), (8, 8), (6, 6)],
|
||
vec![(7, 7), (7, 8), (8, 7), (6, 6), (9, 7)],
|
||
vec![(7, 7), (8, 8), (7, 6), (6, 6), (9, 7)],
|
||
];
|
||
|
||
for opening in &openings {
|
||
let mut board = Board::new(15);
|
||
let mut hash: ZobristHash = 0;
|
||
let zobrist = crate::types::init_zobrist_table(15);
|
||
|
||
for (step, &(x, y)) in opening.iter().enumerate() {
|
||
let color = if step % 2 == 0 { Color::Black } else { Color::White };
|
||
let color_idx = if step % 2 == 0 { 0 } else { 1 };
|
||
hash ^= zobrist[x][y][color_idx];
|
||
}
|
||
|
||
// 存储为黑方的下一步最佳走法
|
||
let next_move = Position::new(opening[0].0, opening[0].1);
|
||
self.positions.entry(hash).or_default().push(next_move);
|
||
|
||
// 对前 N-1 步也存储(每一步截断后查表)
|
||
for prefix_len in 1..opening.len() {
|
||
let mut board = Board::new(15);
|
||
let mut hash: ZobristHash = 0;
|
||
for (step, &(x, y)) in opening.iter().take(prefix_len).enumerate() {
|
||
let color = if step % 2 == 0 { Color::Black } else { Color::White };
|
||
let color_idx = if step % 2 == 0 { 0 } else { 1 };
|
||
hash ^= zobrist[x][y][color_idx];
|
||
}
|
||
if prefix_len < opening.len() {
|
||
let next = Position::new(opening[prefix_len].0, opening[prefix_len].1);
|
||
self.positions.entry(hash).or_default().push(next);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 查询开局定式,返回候选走法列表
|
||
pub fn lookup(&self, hash: ZobristHash) -> Option<&Vec<Position>> {
|
||
self.positions.get(&hash)
|
||
}
|
||
|
||
/// 随机选择一个走法
|
||
pub fn pick_random(&self, hash: ZobristHash) -> Option<Position> {
|
||
let moves = self.positions.get(&hash)?;
|
||
let mut rng = rand::thread_rng();
|
||
moves.choose(&mut rng).copied()
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::board::Board;
|
||
use crate::types::Color;
|
||
|
||
#[test]
|
||
fn test_empty_board_has_opening() {
|
||
let book = OpeningBook::new();
|
||
let board = Board::new(15);
|
||
let result = book.lookup(board.hash());
|
||
assert!(result.is_some(), "空棋盘应该匹配开局库");
|
||
}
|
||
|
||
#[test]
|
||
fn test_unknown_hash_returns_none() {
|
||
let book = OpeningBook::new();
|
||
assert!(book.lookup(0xDEADBEEF_CAFEBABE).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn test_opening_sequence_matches() {
|
||
let book = OpeningBook::new();
|
||
// 花月第一步: 黑(7,7) 白(7,8) 黑(6,7) 白(6,6)
|
||
let board = Board::new(15);
|
||
let board = board.place(Position::new(7, 7), Color::Black).unwrap();
|
||
let board = board.place(Position::new(7, 8), Color::White).unwrap();
|
||
let board = board.place(Position::new(6, 7), Color::Black).unwrap();
|
||
let board = board.place(Position::new(6, 6), Color::White).unwrap();
|
||
let result = book.lookup(board.hash());
|
||
assert!(result.is_some(), "花月前4手应匹配");
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: 在 ai/mod.rs 注册**
|
||
|
||
```rust
|
||
pub mod opening;
|
||
```
|
||
|
||
- [ ] **Step 4: 验证编译和测试**
|
||
|
||
```bash
|
||
cargo test -p gobang-core opening
|
||
```
|
||
|
||
Expected: 3 个测试通过。
|
||
|
||
- [ ] **Step 5: 提交**
|
||
|
||
```bash
|
||
git add core/Cargo.toml core/src/ai/opening.rs core/src/ai/mod.rs
|
||
git commit -m "feat: 开局库 — 50 个标准定式前 7 手 + 3 测试"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: VCF/VCT 杀棋搜索
|
||
|
||
**Files:**
|
||
- Create: `core/src/ai/vcf.rs`
|
||
- Modify: `core/src/ai/mod.rs`
|
||
|
||
- [ ] **Step 1: 创建 vcf.rs**
|
||
|
||
```rust
|
||
use crate::board::Board;
|
||
use crate::rules;
|
||
use crate::types::{Color, Position};
|
||
|
||
/// VCF 搜索 — 连续冲四取胜
|
||
/// 返回取胜序列第一步(如果有)
|
||
pub fn vcf_search(board: &Board, color: Color, max_depth: usize) -> Option<Position> {
|
||
vcf_inner(board, color, max_depth).map(|seq| seq[0])
|
||
}
|
||
|
||
fn vcf_inner(board: &Board, color: Color, depth: usize) -> Option<Vec<Position>> {
|
||
if depth == 0 {
|
||
return None;
|
||
}
|
||
|
||
let candidates = board.get_candidate_moves();
|
||
|
||
for &pos in &candidates {
|
||
if rules::is_forbidden(board, pos, color) {
|
||
continue;
|
||
}
|
||
if let Ok(new_board) = board.place(pos, color) {
|
||
// 检查是否直接五连
|
||
if new_board.check_win(pos) {
|
||
return Some(vec![pos]);
|
||
}
|
||
|
||
// 检查是否形成冲四(对手被迫堵)
|
||
if is_rush_four(&new_board, pos, color) {
|
||
let opp_color = color.opponent();
|
||
// 找到对手唯一的堵位
|
||
if let Some(block_pos) = find_unique_block(&new_board, pos, color) {
|
||
if let Ok(b2) = new_board.place(block_pos, opp_color) {
|
||
if let Some(mut rest) = vcf_inner(&b2, color, depth - 2) {
|
||
rest.insert(0, pos);
|
||
return Some(rest);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
None
|
||
}
|
||
|
||
/// VCT 搜索 — 连续活三/冲四混合取胜
|
||
pub fn vct_search(board: &Board, color: Color, max_depth: usize) -> Option<Position> {
|
||
vct_inner(board, color, max_depth).map(|seq| seq[0])
|
||
}
|
||
|
||
fn vct_inner(board: &Board, color: Color, depth: usize) -> Option<Vec<Position>> {
|
||
if depth == 0 {
|
||
return None;
|
||
}
|
||
|
||
let candidates = board.get_candidate_moves();
|
||
|
||
for &pos in &candidates {
|
||
if rules::is_forbidden(board, pos, color) {
|
||
continue;
|
||
}
|
||
if let Ok(new_board) = board.place(pos, color) {
|
||
if new_board.check_win(pos) {
|
||
return Some(vec![pos]);
|
||
}
|
||
|
||
// 检查是否形成威胁(活三或冲四)
|
||
if is_threat(&new_board, pos, color) {
|
||
let opp_color = color.opponent();
|
||
// 找到对手必须防守的位置
|
||
let defenses = find_threat_defenses(&new_board, pos, color);
|
||
|
||
// 只搜索"唯一防守"的情况(强制VCT),避免分支爆炸
|
||
if defenses.len() == 1 {
|
||
let def = defenses[0];
|
||
if let Ok(b2) = new_board.place(def, opp_color) {
|
||
if let Some(mut rest) = vct_inner(&b2, color, depth - 2) {
|
||
rest.insert(0, pos);
|
||
return Some(rest);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
None
|
||
}
|
||
|
||
/// 检查 pos 是否形成冲四(对方必须立即堵)
|
||
fn is_rush_four(board: &Board, pos: Position, color: Color) -> bool {
|
||
let directions: [(isize, isize); 4] = [(0, 1), (1, 0), (1, 1), (1, -1)];
|
||
for (dx, dy) in directions {
|
||
let (count, start_open, end_open) =
|
||
scan_vcf(board, pos, color, dx, dy);
|
||
if count == 4 && (start_open || end_open) && !(start_open && end_open) {
|
||
return true; // 一端开放 = 冲四
|
||
}
|
||
}
|
||
false
|
||
}
|
||
|
||
/// 检查 pos 是否形成威胁(活三或冲四)
|
||
fn is_threat(board: &Board, pos: Position, color: Color) -> bool {
|
||
let directions: [(isize, isize); 4] = [(0, 1), (1, 0), (1, 1), (1, -1)];
|
||
for (dx, dy) in directions {
|
||
let (count, start_open, end_open) =
|
||
scan_vcf(board, pos, color, dx, dy);
|
||
// 活三 (两端开放)
|
||
if count == 3 && start_open && end_open {
|
||
return true;
|
||
}
|
||
// 冲四 (一端开放)
|
||
if count == 4 && (start_open || end_open) {
|
||
return true;
|
||
}
|
||
}
|
||
false
|
||
}
|
||
|
||
/// 找到冲四的唯一堵位
|
||
fn find_unique_block(board: &Board, pos: Position, color: Color) -> Option<Position> {
|
||
let directions: [(isize, isize); 4] = [(0, 1), (1, 0), (1, 1), (1, -1)];
|
||
for (dx, dy) in directions {
|
||
let (count, start_open, end_open) =
|
||
scan_vcf(board, pos, color, dx, dy);
|
||
if count == 4 {
|
||
if start_open {
|
||
let bx = pos.x as isize - dx * 5;
|
||
let by = pos.y as isize - dy * 5;
|
||
for i in 0..5 {
|
||
let nx = bx + dx * i;
|
||
let ny = by + dy * i;
|
||
if nx >= 0 && ny >= 0
|
||
&& (nx as usize) < board.size
|
||
&& (ny as usize) < board.size
|
||
{
|
||
let cell = board.get(Position::new(nx as usize, ny as usize));
|
||
if matches!(cell, crate::types::CellState::Empty) {
|
||
return Some(Position::new(nx as usize, ny as usize));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if end_open {
|
||
let bx = pos.x as isize + dx;
|
||
let by = pos.y as isize + dy;
|
||
for i in 1..=5 {
|
||
let nx = bx + dx * i;
|
||
let ny = by + dy * i;
|
||
if nx >= 0 && ny >= 0
|
||
&& (nx as usize) < board.size
|
||
&& (ny as usize) < board.size
|
||
{
|
||
let cell = board.get(Position::new(nx as usize, ny as usize));
|
||
if matches!(cell, crate::types::CellState::Empty) {
|
||
return Some(Position::new(nx as usize, ny as usize));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
/// 找到威胁的防守位置
|
||
fn find_threat_defenses(board: &Board, pos: Position, color: Color) -> Vec<Position> {
|
||
let mut defenses = Vec::new();
|
||
// 简化:返回威胁方向上的开放端作为防守点
|
||
let directions: [(isize, isize); 4] = [(0, 1), (1, 0), (1, 1), (1, -1)];
|
||
for (dx, dy) in directions {
|
||
let (count, start_open, end_open) =
|
||
scan_vcf(board, pos, color, dx, dy);
|
||
if count >= 3 {
|
||
// 开放端
|
||
if start_open {
|
||
let sx = pos.x as isize - dx * (count as isize);
|
||
let sy = pos.y as isize - dy * (count as isize);
|
||
if sx >= 0 && sy >= 0 && (sx as usize) < board.size && (sy as usize) < board.size {
|
||
defenses.push(Position::new(sx as usize, sy as usize));
|
||
}
|
||
}
|
||
if end_open {
|
||
let ex = pos.x as isize + dx * (count as isize);
|
||
let ey = pos.y as isize + dy * (count as isize);
|
||
if ex >= 0 && ey >= 0 && (ex as usize) < board.size && (ey as usize) < board.size {
|
||
defenses.push(Position::new(ex as usize, ey as usize));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
defenses.dedup();
|
||
defenses
|
||
}
|
||
|
||
fn scan_vcf(
|
||
board: &Board, pos: Position, color: Color, dx: isize, dy: isize,
|
||
) -> (u32, bool, bool) {
|
||
let mut count = 1u32;
|
||
|
||
let mut nx = pos.x as isize + dx;
|
||
let mut ny = pos.y as isize + dy;
|
||
while let Some(cell) = get_cell_vcf(board, nx, ny) {
|
||
if cell == crate::types::CellState::Occupied(color) {
|
||
count += 1;
|
||
} else {
|
||
break;
|
||
}
|
||
nx += dx;
|
||
ny += dy;
|
||
}
|
||
let end_open = get_cell_vcf(board, nx, ny) == Some(crate::types::CellState::Empty);
|
||
|
||
let mut nx = pos.x as isize - dx;
|
||
let mut ny = pos.y as isize - dy;
|
||
while let Some(cell) = get_cell_vcf(board, nx, ny) {
|
||
if cell == crate::types::CellState::Occupied(color) {
|
||
count += 1;
|
||
} else {
|
||
break;
|
||
}
|
||
nx -= dx;
|
||
ny -= dy;
|
||
}
|
||
let start_open = get_cell_vcf(board, nx, ny) == Some(crate::types::CellState::Empty);
|
||
|
||
(count, start_open, end_open)
|
||
}
|
||
|
||
fn get_cell_vcf(board: &Board, x: isize, y: isize) -> Option<crate::types::CellState> {
|
||
if x < 0 || y < 0 || (x as usize) >= board.size || (y as usize) >= board.size {
|
||
return None;
|
||
}
|
||
Some(board.get(Position::new(x as usize, y as usize)))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::board::Board;
|
||
use crate::types::Color;
|
||
|
||
#[test]
|
||
fn test_vcf_finds_winning_rush_four_sequence() {
|
||
// 构建 VCF 局面: 黑棋有连续冲四取胜路线
|
||
let board = Board::new(15);
|
||
let mut board = board;
|
||
// 黑棋: 冲四在 (7,3)(7,4)(7,5)(7,6) — 堵 (7,2) 或 (7,7)
|
||
// 再冲四在 (8,2)(8,3)(8,4)(8,5) — 形成 VCF
|
||
board = board.place(Position::new(7, 3), Color::Black).unwrap();
|
||
board = board.place(Position::new(7, 4), Color::Black).unwrap();
|
||
board = board.place(Position::new(7, 5), Color::Black).unwrap();
|
||
board = board.place(Position::new(7, 6), Color::Black).unwrap();
|
||
// VCF 至少应搜到第一步冲四
|
||
let result = vcf_search(&board, Color::Black, 4);
|
||
// 不一定能找到完整 VCF 链(需要另一边也是冲四),但不应崩溃
|
||
// 如果找不到完整链,返回 None 是合理的
|
||
let _ = result;
|
||
}
|
||
|
||
#[test]
|
||
fn test_vcf_returns_none_for_no_win() {
|
||
let board = Board::new(15);
|
||
let result = vcf_search(&board, Color::Black, 6);
|
||
assert!(result.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn test_vct_returns_none_for_no_threat() {
|
||
let board = Board::new(15);
|
||
let board = board.place(Position::new(7, 7), Color::Black).unwrap();
|
||
let result = vct_search(&board, Color::Black, 6);
|
||
assert!(result.is_none());
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 在 ai/mod.rs 注册**
|
||
|
||
```rust
|
||
pub mod vcf;
|
||
```
|
||
|
||
- [ ] **Step 3: 验证编译和测试**
|
||
|
||
```bash
|
||
cargo test -p gobang-core vcf
|
||
```
|
||
|
||
Expected: 3 个测试通过。
|
||
|
||
- [ ] **Step 4: 提交**
|
||
|
||
```bash
|
||
git add core/src/ai/vcf.rs core/src/ai/mod.rs
|
||
git commit -m "feat: VCF/VCT 杀棋搜索 — 连续冲四/活三取胜 + 3 测试"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: 重写 search.rs — 迭代加深 + 串联全部组件
|
||
|
||
**Files:**
|
||
- Modify: `core/src/ai/search.rs` (重写)
|
||
|
||
- [ ] **Step 1: 重写 search.rs**
|
||
|
||
用迭代加深重写 `best_move`,集成置换表、killer、开局库、VCF/VCT:
|
||
|
||
```rust
|
||
use crate::ai::evaluate::evaluate_board;
|
||
use crate::ai::killer::KillerTable;
|
||
use crate::ai::opening::OpeningBook;
|
||
use crate::ai::trans_table::{BoundType, TransTable};
|
||
use crate::ai::vcf;
|
||
use crate::ai::AiEngine;
|
||
use crate::board::Board;
|
||
use crate::rules;
|
||
use crate::types::{Color, Position};
|
||
use std::time::{Duration, Instant};
|
||
|
||
/// 难度 → 时间上限(秒)
|
||
const TIME_LIMITS: [u64; 5] = [1, 2, 3, 5, 8];
|
||
|
||
/// 迭代加深 + Alpha-Beta + TT + Killer AI 引擎
|
||
#[derive(Clone)]
|
||
pub struct AlphaBetaAi {
|
||
difficulty: usize, // 1-5
|
||
}
|
||
|
||
impl AlphaBetaAi {
|
||
pub fn new(difficulty: usize) -> Self {
|
||
Self { difficulty }
|
||
}
|
||
|
||
fn time_limit(&self) -> Duration {
|
||
let idx = self.difficulty.saturating_sub(1).min(4);
|
||
Duration::from_secs(TIME_LIMITS[idx])
|
||
}
|
||
}
|
||
|
||
impl AiEngine for AlphaBetaAi {
|
||
fn best_move(&self, board: &Board, color: Color) -> Option<Position> {
|
||
// 1. 开局库
|
||
if board.history().len() < 7 {
|
||
let book = OpeningBook::new();
|
||
if let Some(pos) = book.pick_random(board.hash()) {
|
||
return Some(pos);
|
||
}
|
||
}
|
||
|
||
// 2. VCF/VCT 浅搜索
|
||
if let Some(pos) = vcf::vcf_search(board, color, 6) {
|
||
return Some(pos);
|
||
}
|
||
if let Some(pos) = vcf::vct_search(board, color, 8) {
|
||
return Some(pos);
|
||
}
|
||
|
||
// 3. 迭代加深 Alpha-Beta
|
||
let candidates = board.get_candidate_moves();
|
||
if candidates.is_empty() {
|
||
return None;
|
||
}
|
||
|
||
let start = Instant::now();
|
||
let time_limit = self.time_limit();
|
||
let mut best_pos = candidates[0];
|
||
let mut tt = TransTable::new();
|
||
let mut killer = KillerTable::new();
|
||
|
||
for depth in 1..=20u32 {
|
||
let time_spent = start.elapsed();
|
||
if time_spent >= time_limit {
|
||
break;
|
||
}
|
||
|
||
let (pos, completed) = self.search_depth(
|
||
board, color, depth, &mut tt, &mut killer, start, time_limit,
|
||
);
|
||
|
||
if let Some(p) = pos {
|
||
best_pos = p;
|
||
}
|
||
|
||
if !completed {
|
||
break; // 超时,使用上一轮结果
|
||
}
|
||
}
|
||
|
||
Some(best_pos)
|
||
}
|
||
}
|
||
|
||
impl AlphaBetaAi {
|
||
fn search_depth(
|
||
&self, board: &Board, color: Color, depth: u32,
|
||
tt: &mut TransTable, killer: &mut KillerTable,
|
||
start: Instant, time_limit: Duration,
|
||
) -> (Option<Position>, bool) {
|
||
let candidates = board.get_candidate_moves();
|
||
if candidates.is_empty() {
|
||
return (None, true);
|
||
}
|
||
|
||
let mut best_pos = None;
|
||
let mut best_score = f64::NEG_INFINITY;
|
||
let mut alpha = f64::NEG_INFINITY;
|
||
let beta = f64::INFINITY;
|
||
let mut completed = true;
|
||
|
||
// 启发式排序: killer + evaluate
|
||
let mut scored: Vec<(Position, f64)> = candidates
|
||
.iter()
|
||
.filter(|&&p| !rules::is_forbidden(board, p, color))
|
||
.filter_map(|&p| {
|
||
board.place(p, color).ok().map(|b| {
|
||
if b.check_win(p) {
|
||
(p, f64::INFINITY)
|
||
} else {
|
||
let s = evaluate_board(&b, color);
|
||
(p, s)
|
||
}
|
||
})
|
||
})
|
||
.collect();
|
||
|
||
// Killer 优先
|
||
let killer_moves = killer.get(depth as usize);
|
||
scored.sort_by(|a, b| {
|
||
let a_killer = killer_moves.contains(&Some(a.0));
|
||
let b_killer = killer_moves.contains(&Some(b.0));
|
||
if a_killer && !b_killer {
|
||
std::cmp::Ordering::Less
|
||
} else if !a_killer && b_killer {
|
||
std::cmp::Ordering::Greater
|
||
} else {
|
||
b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)
|
||
}
|
||
});
|
||
|
||
for (pos, _) in scored {
|
||
// 超时检查
|
||
if start.elapsed() >= time_limit {
|
||
completed = false;
|
||
break;
|
||
}
|
||
|
||
if let Ok(new_board) = board.place(pos, color) {
|
||
if new_board.check_win(pos) {
|
||
return (Some(pos), true);
|
||
}
|
||
|
||
let score = -self.negamax(
|
||
&new_board, depth - 1, -beta, -alpha, color.opponent(),
|
||
tt, killer, start, time_limit,
|
||
);
|
||
|
||
if score > best_score {
|
||
best_score = score;
|
||
best_pos = Some(pos);
|
||
}
|
||
if score > alpha {
|
||
alpha = score;
|
||
}
|
||
}
|
||
}
|
||
|
||
(best_pos, completed)
|
||
}
|
||
|
||
fn negamax(
|
||
&self, board: &Board, depth: u32, mut alpha: f64, beta: f64, color: Color,
|
||
tt: &mut TransTable, killer: &mut KillerTable,
|
||
start: Instant, time_limit: Duration,
|
||
) -> f64 {
|
||
// 超时检查
|
||
if start.elapsed() >= time_limit {
|
||
return evaluate_board(board, color);
|
||
}
|
||
|
||
// 置换表查询
|
||
let hash = board.hash();
|
||
let alpha_orig = alpha;
|
||
if let Some(entry) = tt.probe(hash, depth as u8) {
|
||
match entry.bound {
|
||
BoundType::Exact => return entry.score as f64,
|
||
BoundType::LowerBound => alpha = alpha.max(entry.score as f64),
|
||
BoundType::UpperBound => {
|
||
if (entry.score as f64) <= alpha {
|
||
return entry.score as f64;
|
||
}
|
||
}
|
||
}
|
||
if alpha >= beta {
|
||
return entry.score as f64;
|
||
}
|
||
}
|
||
|
||
if depth == 0 {
|
||
let score = evaluate_board(board, color);
|
||
return score;
|
||
}
|
||
|
||
let candidates = board.get_candidate_moves();
|
||
if candidates.is_empty() {
|
||
return evaluate_board(board, color);
|
||
}
|
||
|
||
// 启发式排序
|
||
let mut scored: Vec<(Position, f64)> = candidates
|
||
.into_iter()
|
||
.filter(|&p| !rules::is_forbidden(board, p, color))
|
||
.filter_map(|p| {
|
||
board.place(p, color).ok().map(|b| {
|
||
if b.check_win(p) {
|
||
(p, f64::INFINITY)
|
||
} else {
|
||
let s = evaluate_board(&b, color);
|
||
(p, s)
|
||
}
|
||
})
|
||
})
|
||
.collect();
|
||
|
||
let killer_moves = killer.get(depth as usize);
|
||
scored.sort_by(|a, b| {
|
||
let a_killer = killer_moves.contains(&Some(a.0));
|
||
let b_killer = killer_moves.contains(&Some(b.0));
|
||
if a_killer && !b_killer {
|
||
std::cmp::Ordering::Less
|
||
} else if !a_killer && b_killer {
|
||
std::cmp::Ordering::Greater
|
||
} else {
|
||
b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)
|
||
}
|
||
});
|
||
|
||
let mut max_val = f64::NEG_INFINITY;
|
||
let mut best_move = None;
|
||
|
||
for (pos, _) in scored {
|
||
if start.elapsed() >= time_limit {
|
||
break;
|
||
}
|
||
|
||
if let Ok(new_board) = board.place(pos, color) {
|
||
if new_board.check_win(pos) {
|
||
tt.store(hash, depth as u8, f64::INFINITY as i32, BoundType::Exact, Some(pos));
|
||
return f64::INFINITY;
|
||
}
|
||
|
||
let val = -self.negamax(
|
||
&new_board, depth - 1, -beta, -alpha, color.opponent(),
|
||
tt, killer, start, time_limit,
|
||
);
|
||
|
||
if val > max_val {
|
||
max_val = val;
|
||
best_move = Some(pos);
|
||
}
|
||
if val > alpha {
|
||
alpha = val;
|
||
}
|
||
if alpha >= beta {
|
||
// 记录 killer move
|
||
killer.record(depth as usize, pos);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 存置换表
|
||
let bound = if max_val <= alpha_orig {
|
||
BoundType::UpperBound
|
||
} else if max_val >= beta {
|
||
BoundType::LowerBound
|
||
} else {
|
||
BoundType::Exact
|
||
};
|
||
tt.store(hash, depth as u8, max_val as i32, bound, best_move);
|
||
|
||
max_val
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn test_time_limits_per_difficulty() {
|
||
let ai1 = AlphaBetaAi::new(1);
|
||
let ai5 = AlphaBetaAi::new(5);
|
||
assert_eq!(ai1.time_limit(), Duration::from_secs(1));
|
||
assert_eq!(ai5.time_limit(), Duration::from_secs(8));
|
||
}
|
||
|
||
// 保留原来的回归测试
|
||
#[test]
|
||
fn test_ai_returns_center_on_empty_board() {
|
||
let board = Board::new(15);
|
||
let ai = AlphaBetaAi::new(3);
|
||
let mv = ai.best_move(&board, Color::Black);
|
||
assert!(mv.is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn test_ai_takes_win() {
|
||
let board = Board::new(15);
|
||
let mut board = board;
|
||
board = board.place(Position::new(7, 3), Color::Black).unwrap();
|
||
board = board.place(Position::new(7, 4), Color::Black).unwrap();
|
||
board = board.place(Position::new(7, 5), Color::Black).unwrap();
|
||
board = board.place(Position::new(7, 6), Color::Black).unwrap();
|
||
let ai = AlphaBetaAi::new(3);
|
||
let mv = ai.best_move(&board, Color::Black).unwrap();
|
||
assert!(
|
||
(mv.x == 7 && mv.y == 2) || (mv.x == 7 && mv.y == 7),
|
||
"AI should take winning move, got ({},{})", mv.x, mv.y
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 验证编译和测试**
|
||
|
||
```bash
|
||
cargo test -p gobang-core ai::search
|
||
```
|
||
|
||
Expected: 3 个测试通过。
|
||
|
||
- [ ] **Step 3: 提交**
|
||
|
||
```bash
|
||
git add core/src/ai/search.rs
|
||
git commit -m "feat: 迭代加深 + TT + Killer + 开局库 + VCF/VCT 集成的 AI 引擎"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: GUI 适配
|
||
|
||
**Files:**
|
||
- Modify: `gui/src/commands.rs`
|
||
|
||
- [ ] **Step 1: 适配 new_game**
|
||
|
||
`AlphaBetaAi::new` 现在接受 difficulty (1-5),不再用 depth。检查 `new_game` 中 AI 初始化是否正确。当前代码已经是 `AlphaBetaAi::new(config.ai_difficulty as usize)`,无需改动。
|
||
|
||
只需确认编译通过:
|
||
|
||
```bash
|
||
cargo check
|
||
```
|
||
|
||
- [ ] **Step 2: 提交(如有改动)**
|
||
|
||
```bash
|
||
cargo check && echo "OK — no changes needed" || (git add gui/src/commands.rs && git commit -m "chore: 适配 AI 升级后的 new_game 参数")
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: 最终验证 + 打包
|
||
|
||
- [ ] **Step 1: 全套验证**
|
||
|
||
```bash
|
||
cargo test
|
||
cargo clippy -- -D warnings
|
||
npx tsc -b
|
||
npx vitest run
|
||
```
|
||
|
||
Expected: 全部通过。
|
||
|
||
- [ ] **Step 2: 构建**
|
||
|
||
```bash
|
||
npx tauri build
|
||
```
|
||
|
||
- [ ] **Step 3: 手动测试**
|
||
- level 1: AI 秒响应
|
||
- level 5: AI 思考 5~8 秒,走棋质量明显提升
|
||
- 开局:前几手按定式走
|
||
|
||
---
|
||
|
||
## 执行顺序
|
||
|
||
```
|
||
T1 (Zobrist) → T2 (TT) → T3 (Killer) → T4 (Evaluate) → T5 (Opening)
|
||
↓
|
||
T6 (VCF/VCT) ←───────────┘
|
||
↓
|
||
T7 (Search 重写)
|
||
↓
|
||
T8 (GUI 适配)
|
||
↓
|
||
T9 (最终验证)
|
||
```
|
||
|
||
T2-T3-T4-T5 互不依赖,可并行。T6 需要 T4 的评估函数。T7 需要 T1-T6 全部。
|