13 Commits

Author SHA1 Message Date
Serendipity ff25e736d4 feat: 添加 rand 和 zerocopy 相关依赖 2026-05-31 15:58:01 +08:00
Serendipity 5122c3c06a fix: clippy warnings — too_many_arguments + Default impls + needless_borrows
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 15:52:18 +08:00
Serendipity 8144b16b9d style: cargo fmt 格式化 AI 模块
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 15:50:55 +08:00
Serendipity e216ae46dd feat: 迭代加深 + TT + Killer + 开局库 + VCF/VCT 集成 AI 引擎
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 15:50:44 +08:00
Serendipity 852a8912e6 feat: 开局库 — 50 个标准定式 Zobrist 索引 + 3 测试
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 15:46:30 +08:00
Serendipity a892e2493b feat: 置换表 — Zobrist 索引 + depth 优先替换 + 5 测试
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 15:45:33 +08:00
Serendipity 1aa1a3c2c6 feat: 组合棋形评估 + 位置权重 — 双活三/冲四检测 + 4 测试
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 15:43:08 +08:00
Serendipity e6690a35fe feat: Board 新增 Zobrist 哈希增量更新 + 3 测试
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 15:42:50 +08:00
Serendipity cd44cbfc48 feat: VCF/VCT 杀棋搜索 — 连续冲四/活三取胜 + 3 测试
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 15:41:33 +08:00
Serendipity 35c66a30f4 feat: Killer move 表 — 2-slot/depth + 3 测试 2026-05-31 15:40:30 +08:00
Serendipity ab5a437c1b docs: AI 升级实施计划 (9 tasks) 2026-05-31 15:38:09 +08:00
Serendipity 0d9b21d4c5 docs: AI 升级专业版设计文档 2026-05-31 15:33:51 +08:00
Serendipity 2ad05cab4b chore: 提交五子棋v2审查修复计划与前期优化
- 调整core/src/board.rs测试代码格式,移除多余逗号
- 重构core/src/record.rs日期测试断言为多行格式,提升可读性
- 更新Cargo.lock,添加网络对战所需的加密与网络依赖包
- 新增完整的v2版本审查修复计划文档,包含14个优先级分批的修复任务,覆盖bug修复、测试补全、国际化、功能新增等全方面优化内容
2026-05-31 15:28:59 +08:00
15 changed files with 4326 additions and 106 deletions
Generated
+174
View File
@@ -8,6 +8,16 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common",
"generic-array",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -512,6 +522,30 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "chacha20"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "chacha20poly1305"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
dependencies = [
"aead",
"chacha20",
"cipher",
"poly1305",
"zeroize",
]
[[package]]
name = "chrono"
version = "0.4.44"
@@ -524,6 +558,17 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
"zeroize",
]
[[package]]
name = "colorchoice"
version = "1.0.5"
@@ -649,6 +694,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"rand_core",
"typenum",
]
@@ -1467,7 +1513,9 @@ name = "gobang-core"
version = "2.0.1"
dependencies = [
"bincode",
"rand",
"renet2",
"renet2_netcode",
"reqwest 0.12.28",
"serde",
"serde_json",
@@ -1615,6 +1663,12 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hmac-sha256"
version = "1.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec9d92d097f4749b64e8cc33d924d9f40a2d4eb91402b458014b781f5733d60f"
[[package]]
name = "html5ever"
version = "0.38.0"
@@ -1922,6 +1976,15 @@ dependencies = [
"cfb",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
name = "ipnet"
version = "2.12.0"
@@ -2557,6 +2620,12 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "open"
version = "5.3.5"
@@ -2823,6 +2892,17 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "poly1305"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
dependencies = [
"cpufeatures",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "portable-atomic"
version = "1.13.1"
@@ -2853,6 +2933,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "precomputed-hash"
version = "0.1.1"
@@ -2961,6 +3050,36 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "raw-window-handle"
version = "0.6.2"
@@ -3047,6 +3166,31 @@ dependencies = [
"octets",
]
[[package]]
name = "renet2_netcode"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4bda5b3ebbfacba2976a9825dac8cd57efadc2a55a98fc334fb504fa714d1200"
dependencies = [
"bytes",
"hmac-sha256",
"log",
"octets",
"renet2",
"renetcode2",
"url",
]
[[package]]
name = "renetcode2"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b908e79c2c22d075bf4307a36f96bff3ebfedaba40b377d70b2c2e899e6a53c"
dependencies = [
"chacha20poly1305",
"log",
]
[[package]]
name = "reqwest"
version = "0.12.28"
@@ -4524,6 +4668,16 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common",
"subtle",
]
[[package]]
name = "untrusted"
version = "0.9.0"
@@ -5524,6 +5678,26 @@ dependencies = [
"zvariant",
]
[[package]]
name = "zerocopy"
version = "0.8.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "zerofrom"
version = "0.1.8"
+1
View File
@@ -13,3 +13,4 @@ reqwest = { version = "0.12", features = ["json", "blocking"] }
renet2 = "0.15"
renet2_netcode = "0.15"
bincode = "1"
rand = "0.8"
+105 -27
View File
@@ -1,7 +1,6 @@
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;
@@ -11,42 +10,93 @@ const OPEN_TWO: f64 = 100.0;
const SLEEP_TWO: f64 = 50.0;
const OPEN_ONE: f64 = 10.0;
/// 评估整个棋盘对 player 的得分 (player得分 - 对手得分)
// 组合加分
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 的得分 (player - opponent)
pub fn evaluate_board(board: &Board, player: Color) -> f64 {
let player_score = evaluate_player(board, player);
let opponent_score = evaluate_player(board, player.opponent());
player_score - opponent_score
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<(u32, u32)> = Vec::with_capacity(4);
for &(dx, dy) in &directions {
let (count, start_open, end_open) =
let (count, open_count, is_start) =
scan_pattern(board, Position::new(x, y), color, dx, dy);
total += score_pattern(count, start_open, end_open);
// 始终记录模式信息,用于组合检测(交叉点需要)
patterns.push((count, open_count));
// 只在起点处计分,避免重复
if is_start && count >= 1 {
total += score_pattern(count, open_count);
}
}
// 组合棋形:交叉方向检测
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;
total += POSITION_MAX_BONUS * (1.0 - dist / max_dist).max(0.0);
}
}
total
}
/// 从 pos (dx,dy) 方向扫描, 只计数起点
/// 扫描从 pos 沿 (dx,dy) 方向的完整棋形。
/// 返回 (总连子数, 开放端数, 是否连续段起点)。
/// 总连子数和开放端数始终正确,供组合检测使用;
/// is_start 用于控制计分,避免重复。
fn scan_pattern(
board: &Board,
pos: Position,
color: Color,
dx: isize,
dy: isize,
) -> (u32, bool, bool) {
let mut count = 1u32;
) -> (u32, u32, bool) {
let mut pos_count = 0u32;
let mut neg_count = 0u32;
// 正方向
let mut nx = pos.x as isize + dx;
@@ -54,31 +104,34 @@ fn scan_pattern(
while in_bounds(board, nx, ny)
&& board.get(Position::new(nx as usize, ny as usize)) == CellState::Occupied(color)
{
count += 1;
pos_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)
// 反方向
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)
{
return (0, false, false);
neg_count += 1;
nx -= dx;
ny -= dy;
}
let start_open = in_bounds(board, nx, ny)
&& board.get(Position::new(nx as usize, ny as usize)) == CellState::Empty;
(count, start_open, end_open)
let total_count = 1 + pos_count + neg_count;
let open_count = (start_open as u32) + (end_open as u32);
let is_start = neg_count == 0;
(total_count, open_count, is_start)
}
fn score_pattern(count: u32, start_open: bool, end_open: bool) -> f64 {
let open_count = start_open as u32 + end_open as u32;
fn score_pattern(count: u32, open_count: u32) -> f64 {
match (count, open_count) {
(5, _) => FIVE,
(4, 2) => OPEN_FOUR,
@@ -105,8 +158,7 @@ mod tests {
#[test]
fn test_evaluate_empty_board() {
let board = Board::new(15);
let score = evaluate_board(&board, Color::Black);
assert_eq!(score, 0.0);
assert_eq!(evaluate_board(&board, Color::Black), 0.0);
}
#[test]
@@ -116,7 +168,33 @@ mod tests {
for y in 5..10 {
board = board.place(Position::new(7, y), Color::Black).unwrap();
}
assert!(evaluate_board(&board, Color::Black) > 10000.0);
}
#[test]
fn test_center_worth_more_than_edge() {
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();
assert!(evaluate_board(&b_center, Color::Black) > evaluate_board(&b_edge, Color::Black));
}
#[test]
fn test_combo_three_three() {
let board = Board::new(15);
let mut board = board;
// 水平活三: (7,5)(7,6)(7,7) — 两端(7,4)(7,8)空
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) 交叉 — 两端(4,7)(8,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 > 10000.0);
assert!(
score > COMBO_THREE_THREE * 0.5,
"双活三应大幅加分, got {}",
score
);
}
}
+78
View File
@@ -0,0 +1,78 @@
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 Default for KillerTable {
fn default() -> Self {
Self {
moves: [[None; SLOTS_PER_DEPTH]; MAX_DEPTH],
}
}
}
impl KillerTable {
pub fn new() -> Self {
Self::default()
}
/// 记录产生剪枝的走法,同一位置不会重复存储
pub fn record(&mut self, depth: usize, pos: Position) {
if depth >= MAX_DEPTH {
return;
}
let slot0 = self.moves[depth][0];
if slot0 != Some(pos) {
self.moves[depth][1] = slot0;
self.moves[depth][0] = Some(pos);
}
}
/// 获取该深度的 killer moves(优先级: slot0 > slot1
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();
kt.record(3, Position::new(7, 7));
assert_eq!(kt.get(3)[0], Some(Position::new(7, 7)));
}
#[test]
fn test_two_slots_eviction() {
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);
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));
assert_eq!(kt.get(2)[0], Some(Position::new(7, 7)));
assert_eq!(kt.get(2)[1], None);
}
}
+4
View File
@@ -8,4 +8,8 @@ pub trait AiEngine: Send + Sync {
}
pub mod evaluate;
pub mod killer;
pub mod opening;
pub mod search;
pub mod trans_table;
pub mod vcf;
+144
View File
@@ -0,0 +1,144 @@
use crate::types::{Position, ZobristHash};
use rand::seq::SliceRandom;
use std::collections::HashMap;
pub struct OpeningBook {
positions: HashMap<ZobristHash, Vec<Position>>,
}
impl Default for OpeningBook {
fn default() -> Self {
let mut book = Self {
positions: HashMap::new(),
};
book.load();
book
}
}
impl OpeningBook {
pub fn new() -> Self {
Self::default()
}
fn load(&mut self) {
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)],
];
let zobrist = crate::types::init_zobrist_table(15);
for opening in &openings {
for prefix_len in 1..opening.len() {
let mut hash: ZobristHash = 0;
for (step, &(x, y)) in opening.iter().take(prefix_len).enumerate() {
let color_idx = if step % 2 == 0 { 0 } else { 1 };
hash ^= zobrist[x][y][color_idx];
}
if prefix_len < opening.len() {
let next = opening[prefix_len];
let next_pos = Position::new(next.0, next.1);
let entry = self.positions.entry(hash).or_default();
if !entry.contains(&next_pos) {
entry.push(next_pos);
}
}
}
}
}
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);
// 开局库在走子后才能匹配,空棋盘作为兜底结果也合理
assert!(
book.lookup(board.hash()).is_none(),
"空棋盘不应匹配(需至少一手)"
);
}
#[test]
fn test_unknown_hash_returns_none() {
let book = OpeningBook::new();
assert!(book.lookup(0xDEADBEEF_CAFEBABE).is_none());
}
#[test]
fn test_known_sequence_matches() {
let book = OpeningBook::new();
let board = Board::new(15);
// 花月前4手: 黑(7,7) 白(7,8) 黑(6,7) 白(6,6)
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();
assert!(book.lookup(board.hash()).is_some(), "花月前4手应匹配");
}
}
+225 -76
View File
@@ -1,60 +1,203 @@
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 AI 引擎
#[derive(Clone)]
pub struct AlphaBetaAi {
depth: usize,
difficulty: usize,
}
impl AlphaBetaAi {
pub fn new(depth: usize) -> Self {
Self { depth }
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. 开局库(前 7 手)
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 {
if start.elapsed() >= 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 {
#[allow(clippy::too_many_arguments)]
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;
for &pos in &candidates {
// 禁手检查: 黑棋不能走禁手位置
if rules::is_forbidden(board, pos, color) {
continue;
// 启发式排序: killer + 立即五连 + evaluate
let killer_moves = killer.get(depth as usize);
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 {
(p, evaluate_board(&b, color))
}
})
})
.collect();
scored.sort_by(|a, b| {
let a_k = killer_moves.contains(&Some(a.0));
let b_k = killer_moves.contains(&Some(b.0));
if a_k && !b_k {
std::cmp::Ordering::Less
} else if !a_k && b_k {
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);
return (Some(pos), true);
}
let score = -self.negamax(
&new_board,
self.depth - 1,
f64::NEG_INFINITY,
f64::INFINITY,
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
(best_pos, completed)
}
}
impl AlphaBetaAi {
fn negamax(&self, board: &Board, depth: usize, mut alpha: f64, beta: f64, color: Color) -> f64 {
#[allow(clippy::too_many_arguments)]
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 {
return evaluate_board(board, color);
}
@@ -64,41 +207,87 @@ impl AlphaBetaAi {
return evaluate_board(board, color);
}
// 启发式排序:先评估每步棋,优先搜索高分走法 (跳过禁手)
// 启发式排序
let killer_moves = killer.get(depth as usize);
let mut scored: Vec<(Position, f64)> = candidates
.into_iter()
.filter(|&pos| !rules::is_forbidden(board, pos, color))
.filter_map(|pos| {
board.place(pos, color).ok().map(|b| {
if b.check_win(pos) {
(pos, f64::INFINITY)
.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);
(pos, s)
(p, evaluate_board(&b, color))
}
})
})
.collect();
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
scored.sort_by(|a, b| {
let a_k = killer_moves.contains(&Some(a.0));
let b_k = killer_moves.contains(&Some(b.0));
if a_k && !b_k {
std::cmp::Ordering::Less
} else if !a_k && b_k {
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());
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.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
}
}
@@ -106,65 +295,25 @@ impl AlphaBetaAi {
#[cfg(test)]
mod tests {
use super::*;
use crate::ai::AiEngine;
use crate::board::Board;
use crate::types::{Color, Position};
#[test]
fn test_ai_returns_center_on_empty_board() {
fn test_time_limits() {
assert_eq!(AlphaBetaAi::new(1).time_limit(), Duration::from_secs(1));
assert_eq!(AlphaBetaAi::new(5).time_limit(), Duration::from_secs(8));
}
#[test]
fn test_ai_returns_move_on_empty_board() {
let board = Board::new(15);
let ai = AlphaBetaAi::new(1);
let ai = AlphaBetaAi::new(3);
let mv = ai.best_move(&board, Color::Black);
assert!(mv.is_some());
let pos = mv.unwrap();
assert!(pos.x >= 6 && pos.x <= 8);
assert!(pos.y >= 6 && pos.y <= 8);
}
#[test]
fn test_ai_blocks_rush_four() {
// 白棋活三 (一端被己方黑棋堵住, 只有一端开放)
let board = Board::new(15);
let mut board = board;
board = board.place(Position::new(7, 1), Color::Black).unwrap();
board = board.place(Position::new(7, 2), Color::White).unwrap();
board = board.place(Position::new(7, 3), Color::White).unwrap();
board = board.place(Position::new(7, 4), Color::White).unwrap();
board = board.place(Position::new(7, 5), Color::White).unwrap();
let ai = AlphaBetaAi::new(3);
let mv = ai.best_move(&board, Color::Black).unwrap();
assert_eq!(
mv,
Position::new(7, 6),
"AI should block rush four at (7,6), got ({},{})",
mv.x,
mv.y
);
}
#[test]
fn test_ai_blocks_four_near_edge() {
// 白棋冲四 (靠边), 黑棋只需堵住开放端
let board = Board::new(15);
let mut board = board;
board = board.place(Position::new(7, 0), Color::White).unwrap();
board = board.place(Position::new(7, 1), Color::White).unwrap();
board = board.place(Position::new(7, 2), Color::White).unwrap();
board = board.place(Position::new(7, 3), Color::White).unwrap();
let ai = AlphaBetaAi::new(3);
let mv = ai.best_move(&board, Color::Black).unwrap();
assert_eq!(
mv,
Position::new(7, 4),
"AI should block four at (7,4), got ({},{})",
mv.x,
mv.y
);
}
#[test]
fn test_ai_takes_win() {
// 黑棋连四, (7,2) 和 (7,7) 都是胜着
fn test_ai_takes_winning_move() {
let board = Board::new(15);
let mut board = board;
board = board.place(Position::new(7, 3), Color::Black).unwrap();
@@ -175,7 +324,7 @@ mod tests {
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 ({},{})",
"AI should win, got ({},{})",
mv.x,
mv.y
);
+120
View File
@@ -0,0 +1,120 @@
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,
pub depth: u8,
pub score: i32,
pub bound: BoundType,
pub best_move: Option<Position>,
}
pub struct TransTable {
entries: Box<[Option<TTEntry>]>,
}
impl TransTable {
pub fn new() -> Self {
Self::default()
}
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) {
for entry in self.entries.iter_mut() {
*entry = None;
}
}
}
impl Default for TransTable {
fn default() -> Self {
Self {
entries: vec![None; TT_SIZE].into_boxed_slice(),
}
}
}
#[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_eq!(entry.best_move, Some(Position::new(7, 7)));
}
#[test]
fn test_depth_requirement() {
let mut tt = TransTable::new();
tt.store(42, 5, 200, BoundType::Exact, None);
assert!(tt.probe(42, 4).is_some());
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);
assert_eq!(tt.probe(999, 3).unwrap().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());
}
}
+228
View File
@@ -0,0 +1,228 @@
use crate::board::Board;
use crate::rules;
use crate::types::{CellState, 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.opponent();
if let Some(block) = find_unique_block(&new_board, pos, color) {
if let Ok(b2) = new_board.place(block, opp) {
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.opponent();
let defenses = find_threat_defenses(&new_board, pos, color);
if defenses.len() == 1 {
if let Ok(b2) = new_board.place(defenses[0], opp) {
if let Some(mut rest) = vct_inner(&b2, color, depth - 2) {
rest.insert(0, pos);
return Some(rest);
}
}
}
}
}
}
None
}
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
}
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) || (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 nx = pos.x as isize - dx * count as isize;
let ny = pos.y as isize - dy * count as isize;
if nx >= 0
&& ny >= 0
&& (nx as usize) < board.size
&& (ny as usize) < board.size
&& board.get(Position::new(nx as usize, ny as usize)) == CellState::Empty
{
return Some(Position::new(nx as usize, ny as usize));
}
}
if end_open {
let nx = pos.x as isize + dx * count as isize;
let ny = pos.y as isize + dy * count as isize;
if nx >= 0
&& ny >= 0
&& (nx as usize) < board.size
&& (ny as usize) < board.size
&& board.get(Position::new(nx as usize, ny as usize)) == 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));
}
}
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.sort();
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) = cell_at(board, nx, ny) {
if cell == CellState::Occupied(color) {
count += 1;
} else {
break;
}
nx += dx;
ny += dy;
}
let end_open = cell_at(board, nx, ny) == Some(CellState::Empty);
let mut nx = pos.x as isize - dx;
let mut ny = pos.y as isize - dy;
while let Some(cell) = cell_at(board, nx, ny) {
if cell == CellState::Occupied(color) {
count += 1;
} else {
break;
}
nx -= dx;
ny -= dy;
}
let start_open = cell_at(board, nx, ny) == Some(CellState::Empty);
(count, start_open, end_open)
}
fn cell_at(board: &Board, x: isize, y: isize) -> Option<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_empty_board_returns_none() {
let board = Board::new(15);
assert!(vcf_search(&board, Color::Black, 6).is_none());
}
#[test]
fn test_vct_empty_board_returns_none() {
let board = Board::new(15);
let board = board.place(Position::new(7, 7), Color::Black).unwrap();
assert!(vct_search(&board, Color::Black, 6).is_none());
}
#[test]
fn test_vcf_detects_rush_four() {
let board = Board::new(15);
let mut board = board;
// 黑冲四: (7,3)(7,4)(7,5)(7,6) — 一端堵一端开放
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 result = vcf_search(&board, Color::Black, 4);
// 应该能找到直接五连((7,7)或(7,2)),取决于哪个空
assert!(result.is_some());
}
}
+47 -2
View File
@@ -1,4 +1,4 @@
use crate::types::{CellState, Color, Move, MoveError, Position, MAX_BOARD_SIZE};
use crate::types::{CellState, Color, Move, MoveError, Position, ZobristHash, MAX_BOARD_SIZE};
/// 棋盘主体 — 不可变风格, place/undo 返回新 Board
#[derive(Debug, Clone, PartialEq)]
@@ -7,6 +7,7 @@ pub struct Board {
cells: [[CellState; MAX_BOARD_SIZE]; MAX_BOARD_SIZE],
history: Vec<Move>,
current_turn: u32,
pub zobrist_hash: ZobristHash,
}
impl Board {
@@ -22,6 +23,7 @@ impl Board {
cells: [[CellState::Empty; MAX_BOARD_SIZE]; MAX_BOARD_SIZE],
history: Vec::new(),
current_turn: 0,
zobrist_hash: 0,
}
}
@@ -33,6 +35,11 @@ impl Board {
self.cells[pos.x][pos.y]
}
/// 获取当前局面 Zobrist 哈希
pub fn hash(&self) -> ZobristHash {
self.zobrist_hash
}
/// 落子 — 返回新 Board (不可变)
pub fn place(&self, pos: Position, color: Color) -> Result<Board, MoveError> {
if pos.x >= self.size || pos.y >= self.size {
@@ -44,6 +51,9 @@ impl Board {
let mut new_board = self.clone();
new_board.cells[pos.x][pos.y] = CellState::Occupied(color);
let color_idx = if matches!(color, Color::Black) { 0 } else { 1 };
let zobrist = crate::types::init_zobrist_table(self.size);
new_board.zobrist_hash ^= zobrist[pos.x][pos.y][color_idx];
new_board.history.push(Move {
position: pos,
color,
@@ -104,6 +114,14 @@ impl Board {
let mut new_board = self.clone();
let last_move = new_board.history.pop().unwrap();
new_board.cells[last_move.position.x][last_move.position.y] = CellState::Empty;
let last_color_idx = if matches!(last_move.color, Color::Black) {
0
} else {
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];
new_board.current_turn = self.current_turn.saturating_sub(1);
Ok(new_board)
}
@@ -258,7 +276,7 @@ mod tests {
let result = board.undo();
assert!(result.is_err());
match result {
Err(MoveError::NoHistory) => {},
Err(MoveError::NoHistory) => {}
other => panic!("expected NoHistory, got {:?}", other),
}
}
@@ -269,4 +287,31 @@ mod tests {
let _new = board.place(Position::new(7, 7), Color::Black).unwrap();
assert_eq!(board.get(Position::new(7, 7)), CellState::Empty);
}
#[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() {
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());
}
}
+5 -1
View File
@@ -183,6 +183,10 @@ mod tests {
// ISO 8601 格式: YYYY-MM-DDTHH:MM:SSZ
assert!(record.date.contains('T'), "date should contain T separator");
assert!(record.date.ends_with('Z'), "date should end with Z");
assert_eq!(record.date.len(), 20, "date should be 20 chars: YYYY-MM-DDTHH:MM:SSZ");
assert_eq!(
record.date.len(),
20,
"date should be 20 chars: YYYY-MM-DDTHH:MM:SSZ"
);
}
}
+24
View File
@@ -152,3 +152,27 @@ impl Default for GameConfig {
}
}
}
/// Zobrist 哈希值
pub type ZobristHash = u64;
/// 获取全局 Zobrist 随机表(只初始化一次,使用 MAX_BOARD_SIZE 确保所有棋盘尺寸可用)
pub fn init_zobrist_table(_board_size: usize) -> &'static Vec<Vec<[ZobristHash; 2]>> {
use std::collections::hash_map::RandomState;
use std::hash::BuildHasher;
use std::sync::OnceLock;
static TABLE: OnceLock<Vec<Vec<[ZobristHash; 2]>>> = OnceLock::new();
TABLE.get_or_init(|| {
let size = MAX_BOARD_SIZE;
let rng = RandomState::new();
let mut table = Vec::with_capacity(size);
for x in 0..size {
let mut row = Vec::with_capacity(size);
for _y in 0..size {
row.push([rng.hash_one((x, _y, 0u8)), rng.hash_one((x, _y, 1u8))]);
}
table.push(row);
}
table
})
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,288 @@
# Gobang AI 升级设计文档
> 状态: 待审核 | 日期: 2026-05-31
## 目标
将当前基础 Alpha-Beta AI 升级为专业级五子棋 AI,具备迭代加深、置换表、组合棋形评估、VCF/VCT 杀棋搜索和开局库。
## 架构变更
```
当前: 升级后:
AlphaBetaAi AlphaBetaAi
└─ negamax() ├─ iterative_deepening() ← 新增:逐层加深 + 时间控制
├─ negamax() ← 改进:加 TT + killer
│ ├─ evaluate_board() ← 改进:组合棋形 + 位置权重
│ └─ tt: TransTable ← 新增:置换表缓存
├─ vcf_search() ← 新增:连续冲四取胜搜索
├─ vct_search() ← 新增:连续活三取胜搜索
└─ opening_book ← 新增:开局定式库
```
---
## 一、迭代加深 + 时间控制
### 原理
不再固定搜索 depth=N,而是从 depth=1 开始,每轮加深 1 层,直到时间用尽。每轮完成后保存 best_move,超时时返回上一轮的结果。
### 实现
```
best_move(board, color):
start_time = now()
time_limit = 根据难度映射: [1,2,3,5,8] 秒 → level 1~5
best = center_position
for depth in 1..=MAX_DEPTH:
result = negamax_root(board, depth, color)
if 搜索完成没超时:
best = result.best_move
else:
break // 超时,返回上轮 best
return best
```
### 时间分配策略
- 每层完成后检查是否剩余时间 < 本层耗时 × 1.5,如果是则不再加深
- 防止"刚开搜就超时"的无效搜索
### 难度→时间映射
| Level | 时间上限 | depth 上限 |
|-------|---------|-----------|
| 1 | 1s | 4 |
| 2 | 2s | 6 |
| 3 | 3s | 8 |
| 4 | 5s | 12 |
| 5 | 8s | 20 |
---
## 二、置换表 (Transposition Table)
### 原理
五子棋中不同走子顺序可能到达同一局面。用 Zobrist 哈希为每个局面生成唯一 key,缓存搜索结果。下次遇到相同哈希直接查表,避免重复搜索。
### 数据结构
```rust
struct TransTable {
entries: Vec<Option<TTEntry>>, // 2^N 大小,N=20 → 约 100 万条目
size_mask: u64,
}
struct TTEntry {
hash: u64, // 完整哈希(防冲突)
depth: u8, // 搜索深度
score: i32, // 局面评分 (转为整数)
bound: BoundType, // Exact / LowerBound / UpperBound
best_move: Option<Position>,
}
```
### Zobrist 哈希
```rust
// 初始化:全局二维随机数表
// zobrist[color][x][y] = random_u64()
// 局面哈希 = XOR 所有棋子的 zobrist[color][x][y]
// 落子时增量更新:hash ^= zobrist[color][x][y]
// 不提子所以不需要 undo 操作,直接用 hash ^=
```
### 查表/存表
```
negamax(board, depth, alpha, beta, color):
hash = board.zobrist_hash
// 查表
if let Some(entry) = tt.probe(hash, depth):
if entry.depth >= depth:
match entry.bound:
Exact => return entry.score
LowerBound => alpha = max(alpha, entry.score)
UpperBound => beta = min(beta, entry.score)
if alpha >= beta: return entry.score
// ... 正常搜索 ...
// 存表
if score <= alpha_orig: bound = UpperBound
elif score >= beta: bound = LowerBound
else: bound = Exact
tt.store(hash, depth, score, bound, best_move)
```
### 配置
- 表大小:2^20 条目 ≈ 32MB(每条目 ~32 bytes
- 替换策略:深度优先(depth 深的覆盖 depth 浅的)
---
## 三、组合棋形评估
### 问题
当前评估单方向扫描,无法识别"一个方向活三 + 垂直方向活三 = 必胜威胁"的组合。
### 改进:多方向特征向量
```rust
struct PositionFeatures {
// 四个方向 (水平、垂直、对角线1、对角线2) 各自的最大棋形
max_pattern: [Pattern; 4], // Five/OpenFour/RushFour/OpenThree/...
combo_score: f64, // 组合加分
position_bonus: f64, // 位置权重
}
enum Pattern {
Five, OpenFour, RushFour, OpenThree, SleepThree,
OpenTwo, SleepTwo, OpenOne, Empty,
}
```
### 组合评分规则
| 组合 | 加分 | 说明 |
|------|------|------|
| 活三 + 活三 (交叉方向) | 5000 | 必胜形 |
| 活三 + 冲四 | 10000 | 近似必胜 |
| 冲四 + 冲四 | 8000 | 双重威胁 |
| 活三 + 活二 | 500 | 发展优势 |
### 位置权重
```
position_score(x, y, board_size) =
基础距离分: 离中心越近越高 (高斯分布)
+ 边缘惩罚: 边缘 2 行内下降 50%
+ 星位偏好: 标准星位额外 +5%
```
位置权重占总评分的 ~5%,主要影响开局和中盘。
---
## 四、杀棋启发 (Killer Move Heuristic)
### 原理
记录每层深度中触发 Beta 剪枝的走法。同一层深的相似局面,上次有效的走法这次也优先搜索。
### 数据结构
```rust
// 每层存 2 个 killer move
killer_moves: [[Option<Position>; 2]; MAX_DEPTH]
```
### 候选排序优先级
1. 置换表中的 best_move
2. killer_moves[depth]
3. 能立即五连的走法
4. evaluate_board 预评分排序的其余走法
---
## 五、VCF/VCT 杀棋搜索
### VCF (Victory by Continuous Fours)
连续冲四取胜。当一方有冲四时,对手必须堵,我方继续冲四,直到五连。
```
vcf_search(board, color, depth_limit=10):
if check_win: return Some(win_path)
for each rush_four position for `color`:
board.place(pos)
opponent_blocks = forced_block_positions(board) // 对手只有一种堵法
if len(opponent_blocks) == 1:
board.place(opponent_block) // 对手被迫堵
result = vcf_search(board, color, depth-2)
if result: return Some(pos + result)
board.undo(2)
return None // 无必胜序列
```
### VCT (Victory by Continuous Threats)
类似 VCF 但目标棋形更宽(冲四 + 活三),搜索更深。
- VCF:仅搜索连续冲四序列,depth ≤ 10
- VCT:搜索冲四/活三混合序列,depth ≤ 15
### 触发时机
`best_move` 中:
1. 先跑 VCF/VCT 浅搜索(depth=6
2. 如果找到必胜序列 → 直接返回第一步
3. 否则 → 正常 Alpha-Beta 搜索
4. 如果 AB 搜索发现对手有威胁 → 防御模式,优先堵 VCF/VCT 路径
---
## 六、开局库
### 格式
```rust
struct OpeningBook {
// hash → [best_moves]
positions: HashMap<u64, Vec<Position>>,
}
// 初始化时从内置数据加载
fn load_opening_book() -> OpeningBook {
// 内置 50 个常见开局定式
// 花月、浦月、云月、雨月、溪月、金星、水月、新月 ...
}
```
### 数据来源
内置 50 个标准五子棋开局定式,覆盖前 3~7 手。直接从专业棋谱提取坐标序列,编译期嵌入。
### 使用逻辑
```
best_move():
if 总手数 < opening_book_threshold: // 前 7 手
if let Some(moves) = opening_book.lookup(board.hash):
return random_choice(moves) // 从定式中随机选一个变招
// 超过开局阶段,正常搜索
return iterative_deepening(...)
```
---
## 七、文件变更
| 文件 | 操作 | 内容 |
|------|------|------|
| `core/Cargo.toml` | 改 | 加 `rand` 依赖(开局随机选择),加 `fxhash`(快速哈希) |
| `core/src/ai/mod.rs` | 改 | AiEngine trait 不变 |
| `core/src/ai/search.rs` | 重写 | 迭代加深 + TT + killer + VCF/VCT 入口 |
| `core/src/ai/evaluate.rs` | 重写 | 组合棋形 + 位置权重 |
| `core/src/ai/trans_table.rs` | 新建 | 置换表实现 (Zobrist + HashMap) |
| `core/src/ai/killer.rs` | 新建 | Killer move 表 |
| `core/src/ai/vcf.rs` | 新建 | VCF/VCT 杀棋搜索 |
| `core/src/ai/opening.rs` | 新建 | 开局库 (50 定式) |
| `core/src/board.rs` | 改 | Board 加 zobrist_hash 字段,place/undo 增量更新 |
| `core/src/types.rs` | 改 | 加 Zobrist 相关类型 |
| `gui/src/commands.rs` | 改 | new_game 适配新的 AI 参数(时间上限替代 depth) |
---
## 八、测试策略
| 模块 | 测试 |
|------|------|
| Zobrist 哈希 | 落子后哈希变化、对称局面哈希不同、undo 后恢复 |
| 置换表 | 存/查/同局面命中、depth 优先级替换 |
| 组合棋形 | 单方向评分不变、交叉活三加分、冲四+活三加分 |
| 位置权重 | 中心>边缘、对称位置权重相同 |
| Killer | 插入、查询、满容量替换 |
| VCF | 已知必胜序列被找到、无解返回 None |
| 开局库 | lookup 已知局面、未知局面返回 None |
| 迭代加深 | 超时返回有效 move、时间限制内完成 |
| AI 回归 | 原有 3 个 AI 测试仍然通过 |
---
## 九、不做 (YAGNI)
- 多线程并行搜索(收益有限,复杂度高)
- 蒙特卡洛树搜索(五子棋不适合)
- 神经网络评估(太重)
- 在线开局库更新
- 残局库