mirror of
https://github.com/LHY0125/Gobang-Game.git
synced 2026-06-29 17:15:55 +08:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5122c3c06a | |||
| 8144b16b9d | |||
| e216ae46dd | |||
| 852a8912e6 | |||
| a892e2493b | |||
| 1aa1a3c2c6 | |||
| e6690a35fe | |||
| cd44cbfc48 | |||
| 35c66a30f4 | |||
| ab5a437c1b | |||
| 0d9b21d4c5 | |||
| 2ad05cab4b | |||
| bda917ce37 | |||
| 9aa9de6b74 | |||
| 33c1f8d8d4 | |||
| 92894d2904 | |||
| af64a99987 | |||
| 6da7b7a43b | |||
| 105aa968e0 | |||
| f2898880eb |
Generated
+292
-2
@@ -8,6 +8,16 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
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]]
|
[[package]]
|
||||||
name = "aho-corasick"
|
name = "aho-corasick"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
@@ -41,6 +51,56 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstream"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"anstyle-parse",
|
||||||
|
"anstyle-query",
|
||||||
|
"anstyle-wincon",
|
||||||
|
"colorchoice",
|
||||||
|
"is_terminal_polyfill",
|
||||||
|
"utf8parse",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle"
|
||||||
|
version = "1.0.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-parse"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
|
||||||
|
dependencies = [
|
||||||
|
"utf8parse",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-query"
|
||||||
|
version = "1.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-wincon"
|
||||||
|
version = "3.0.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"once_cell_polyfill",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyhow"
|
name = "anyhow"
|
||||||
version = "1.0.102"
|
version = "1.0.102"
|
||||||
@@ -225,6 +285,15 @@ version = "0.22.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bincode"
|
||||||
|
version = "1.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bit-set"
|
name = "bit-set"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@@ -453,6 +522,30 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
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]]
|
[[package]]
|
||||||
name = "chrono"
|
name = "chrono"
|
||||||
version = "0.4.44"
|
version = "0.4.44"
|
||||||
@@ -465,6 +558,23 @@ dependencies = [
|
|||||||
"windows-link 0.2.1",
|
"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"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "combine"
|
name = "combine"
|
||||||
version = "4.6.7"
|
version = "4.6.7"
|
||||||
@@ -584,6 +694,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"generic-array",
|
"generic-array",
|
||||||
|
"rand_core",
|
||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -901,6 +1012,29 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "env_filter"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"regex",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "env_logger"
|
||||||
|
version = "0.11.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a"
|
||||||
|
dependencies = [
|
||||||
|
"anstream",
|
||||||
|
"anstyle",
|
||||||
|
"env_filter",
|
||||||
|
"jiff",
|
||||||
|
"log",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
@@ -1376,8 +1510,11 @@ checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gobang-core"
|
name = "gobang-core"
|
||||||
version = "2.0.0"
|
version = "2.0.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"bincode",
|
||||||
|
"renet2",
|
||||||
|
"renet2_netcode",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -1385,9 +1522,12 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gobang-gui"
|
name = "gobang-gui"
|
||||||
version = "2.0.0"
|
version = "2.0.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"env_logger",
|
||||||
"gobang-core",
|
"gobang-core",
|
||||||
|
"log",
|
||||||
|
"renet2",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
@@ -1522,6 +1662,12 @@ version = "0.4.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hmac-sha256"
|
||||||
|
version = "1.1.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ec9d92d097f4749b64e8cc33d924d9f40a2d4eb91402b458014b781f5733d60f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "html5ever"
|
name = "html5ever"
|
||||||
version = "0.38.0"
|
version = "0.38.0"
|
||||||
@@ -1829,6 +1975,15 @@ dependencies = [
|
|||||||
"cfb",
|
"cfb",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "inout"
|
||||||
|
version = "0.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipnet"
|
name = "ipnet"
|
||||||
version = "2.12.0"
|
version = "2.12.0"
|
||||||
@@ -1854,6 +2009,12 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "is_terminal_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.18"
|
version = "1.0.18"
|
||||||
@@ -1883,6 +2044,30 @@ dependencies = [
|
|||||||
"system-deps",
|
"system-deps",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jiff"
|
||||||
|
version = "0.2.28"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4603d3033e49e2b0e31229fcab20a5d40089c607d975cd9c80551dc69eed9102"
|
||||||
|
dependencies = [
|
||||||
|
"jiff-static",
|
||||||
|
"log",
|
||||||
|
"portable-atomic",
|
||||||
|
"portable-atomic-util",
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jiff-static"
|
||||||
|
version = "0.2.28"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "782d32378dddf207193ac91cefb848ad41abb58195c95168e1291227a0832b47"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.117",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jni"
|
name = "jni"
|
||||||
version = "0.21.1"
|
version = "0.21.1"
|
||||||
@@ -2416,12 +2601,30 @@ dependencies = [
|
|||||||
"objc2-foundation",
|
"objc2-foundation",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "octets"
|
||||||
|
version = "0.3.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8311fa8ab7a57759b4ff1f851a3048d9ef0effaa0130726426b742d26d8a88e7"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.4"
|
version = "1.21.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell_polyfill"
|
||||||
|
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]]
|
[[package]]
|
||||||
name = "open"
|
name = "open"
|
||||||
version = "5.3.5"
|
version = "5.3.5"
|
||||||
@@ -2688,6 +2891,32 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "portable-atomic-util"
|
||||||
|
version = "0.2.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
|
||||||
|
dependencies = [
|
||||||
|
"portable-atomic",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -2811,6 +3040,15 @@ version = "6.0.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
|
[[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]]
|
[[package]]
|
||||||
name = "raw-window-handle"
|
name = "raw-window-handle"
|
||||||
version = "0.6.2"
|
version = "0.6.2"
|
||||||
@@ -2886,6 +3124,42 @@ version = "0.8.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "renet2"
|
||||||
|
version = "0.15.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "689381686b0760f174e973d038df220eec2834d64345c2b609212d40679dc274"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"log",
|
||||||
|
"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]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
version = "0.12.28"
|
version = "0.12.28"
|
||||||
@@ -4363,6 +4637,16 @@ version = "0.2.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
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]]
|
[[package]]
|
||||||
name = "untrusted"
|
name = "untrusted"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -4406,6 +4690,12 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf8parse"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uuid"
|
name = "uuid"
|
||||||
version = "1.23.2"
|
version = "1.23.2"
|
||||||
|
|||||||
@@ -10,3 +10,7 @@ repository.workspace = true
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
reqwest = { version = "0.12", features = ["json", "blocking"] }
|
reqwest = { version = "0.12", features = ["json", "blocking"] }
|
||||||
|
renet2 = "0.15"
|
||||||
|
renet2_netcode = "0.15"
|
||||||
|
bincode = "1"
|
||||||
|
rand = "0.8"
|
||||||
|
|||||||
+105
-27
@@ -1,7 +1,6 @@
|
|||||||
use crate::board::Board;
|
use crate::board::Board;
|
||||||
use crate::types::{CellState, Color, Position};
|
use crate::types::{CellState, Color, Position};
|
||||||
|
|
||||||
/// 棋形分数
|
|
||||||
const FIVE: f64 = 100000.0;
|
const FIVE: f64 = 100000.0;
|
||||||
const OPEN_FOUR: f64 = 10000.0;
|
const OPEN_FOUR: f64 = 10000.0;
|
||||||
const RUSH_FOUR: f64 = 5000.0;
|
const RUSH_FOUR: f64 = 5000.0;
|
||||||
@@ -11,42 +10,93 @@ const OPEN_TWO: f64 = 100.0;
|
|||||||
const SLEEP_TWO: f64 = 50.0;
|
const SLEEP_TWO: f64 = 50.0;
|
||||||
const OPEN_ONE: f64 = 10.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 {
|
pub fn evaluate_board(board: &Board, player: Color) -> f64 {
|
||||||
let player_score = evaluate_player(board, player);
|
let p_score = evaluate_player(board, player);
|
||||||
let opponent_score = evaluate_player(board, player.opponent());
|
let o_score = evaluate_player(board, player.opponent());
|
||||||
player_score - opponent_score
|
p_score - o_score
|
||||||
}
|
}
|
||||||
|
|
||||||
fn evaluate_player(board: &Board, color: Color) -> f64 {
|
fn evaluate_player(board: &Board, color: Color) -> f64 {
|
||||||
let directions: [(isize, isize); 4] = [(0, 1), (1, 0), (1, 1), (1, -1)];
|
let directions: [(isize, isize); 4] = [(0, 1), (1, 0), (1, 1), (1, -1)];
|
||||||
let mut total = 0.0f64;
|
let mut total = 0.0f64;
|
||||||
let size = board.size;
|
let size = board.size;
|
||||||
|
let center = (size as f64 - 1.0) / 2.0;
|
||||||
|
|
||||||
for x in 0..size {
|
for x in 0..size {
|
||||||
for y in 0..size {
|
for y in 0..size {
|
||||||
if board.get(Position::new(x, y)) != CellState::Occupied(color) {
|
if board.get(Position::new(x, y)) != CellState::Occupied(color) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut patterns: Vec<(u32, u32)> = Vec::with_capacity(4);
|
||||||
for &(dx, dy) in &directions {
|
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);
|
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
|
total
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 从 pos 向 (dx,dy) 方向扫描, 只计数起点
|
/// 扫描从 pos 沿 (dx,dy) 方向的完整棋形。
|
||||||
|
/// 返回 (总连子数, 开放端数, 是否连续段起点)。
|
||||||
|
/// 总连子数和开放端数始终正确,供组合检测使用;
|
||||||
|
/// is_start 用于控制计分,避免重复。
|
||||||
fn scan_pattern(
|
fn scan_pattern(
|
||||||
board: &Board,
|
board: &Board,
|
||||||
pos: Position,
|
pos: Position,
|
||||||
color: Color,
|
color: Color,
|
||||||
dx: isize,
|
dx: isize,
|
||||||
dy: isize,
|
dy: isize,
|
||||||
) -> (u32, bool, bool) {
|
) -> (u32, u32, bool) {
|
||||||
let mut count = 1u32;
|
let mut pos_count = 0u32;
|
||||||
|
let mut neg_count = 0u32;
|
||||||
|
|
||||||
// 正方向
|
// 正方向
|
||||||
let mut nx = pos.x as isize + dx;
|
let mut nx = pos.x as isize + dx;
|
||||||
@@ -54,31 +104,34 @@ fn scan_pattern(
|
|||||||
while in_bounds(board, nx, ny)
|
while in_bounds(board, nx, ny)
|
||||||
&& board.get(Position::new(nx as usize, ny as usize)) == CellState::Occupied(color)
|
&& board.get(Position::new(nx as usize, ny as usize)) == CellState::Occupied(color)
|
||||||
{
|
{
|
||||||
count += 1;
|
pos_count += 1;
|
||||||
nx += dx;
|
nx += dx;
|
||||||
ny += dy;
|
ny += dy;
|
||||||
}
|
}
|
||||||
let end_open = in_bounds(board, nx, ny)
|
let end_open = in_bounds(board, nx, ny)
|
||||||
&& board.get(Position::new(nx as usize, ny as usize)) == CellState::Empty;
|
&& board.get(Position::new(nx as usize, ny as usize)) == CellState::Empty;
|
||||||
|
|
||||||
// 反方向 (检查是否是起点)
|
// 反方向
|
||||||
let sx = pos.x as isize - dx;
|
let mut nx = pos.x as isize - dx;
|
||||||
let sy = pos.y as isize - dy;
|
let mut ny = pos.y as isize - dy;
|
||||||
let start_open = in_bounds(board, sx, sy)
|
while in_bounds(board, nx, ny)
|
||||||
&& board.get(Position::new(sx as usize, sy as usize)) == CellState::Empty;
|
&& board.get(Position::new(nx as usize, ny as usize)) == CellState::Occupied(color)
|
||||||
|
|
||||||
// 如果不是连续段的起点, 不计分 (避免重复)
|
|
||||||
if in_bounds(board, sx, sy)
|
|
||||||
&& board.get(Position::new(sx as usize, sy 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 {
|
fn score_pattern(count: u32, open_count: u32) -> f64 {
|
||||||
let open_count = start_open as u32 + end_open as u32;
|
|
||||||
match (count, open_count) {
|
match (count, open_count) {
|
||||||
(5, _) => FIVE,
|
(5, _) => FIVE,
|
||||||
(4, 2) => OPEN_FOUR,
|
(4, 2) => OPEN_FOUR,
|
||||||
@@ -105,8 +158,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_evaluate_empty_board() {
|
fn test_evaluate_empty_board() {
|
||||||
let board = Board::new(15);
|
let board = Board::new(15);
|
||||||
let score = evaluate_board(&board, Color::Black);
|
assert_eq!(evaluate_board(&board, Color::Black), 0.0);
|
||||||
assert_eq!(score, 0.0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -116,7 +168,33 @@ mod tests {
|
|||||||
for y in 5..10 {
|
for y in 5..10 {
|
||||||
board = board.place(Position::new(7, y), Color::Black).unwrap();
|
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);
|
let score = evaluate_board(&board, Color::Black);
|
||||||
assert!(score > 10000.0);
|
assert!(
|
||||||
|
score > COMBO_THREE_THREE * 0.5,
|
||||||
|
"双活三应大幅加分, got {}",
|
||||||
|
score
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,4 +8,8 @@ pub trait AiEngine: Send + Sync {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub mod evaluate;
|
pub mod evaluate;
|
||||||
|
pub mod killer;
|
||||||
|
pub mod opening;
|
||||||
pub mod search;
|
pub mod search;
|
||||||
|
pub mod trans_table;
|
||||||
|
pub mod vcf;
|
||||||
|
|||||||
@@ -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
@@ -1,60 +1,203 @@
|
|||||||
use crate::ai::evaluate::evaluate_board;
|
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::ai::AiEngine;
|
||||||
use crate::board::Board;
|
use crate::board::Board;
|
||||||
use crate::rules;
|
use crate::rules;
|
||||||
use crate::types::{Color, Position};
|
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)]
|
#[derive(Clone)]
|
||||||
pub struct AlphaBetaAi {
|
pub struct AlphaBetaAi {
|
||||||
depth: usize,
|
difficulty: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AlphaBetaAi {
|
impl AlphaBetaAi {
|
||||||
pub fn new(depth: usize) -> Self {
|
pub fn new(difficulty: usize) -> Self {
|
||||||
Self { depth }
|
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 {
|
impl AiEngine for AlphaBetaAi {
|
||||||
fn best_move(&self, board: &Board, color: Color) -> Option<Position> {
|
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();
|
let candidates = board.get_candidate_moves();
|
||||||
if candidates.is_empty() {
|
if candidates.is_empty() {
|
||||||
return None;
|
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_pos = None;
|
||||||
let mut best_score = f64::NEG_INFINITY;
|
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 {
|
// 启发式排序: killer + 立即五连 + evaluate
|
||||||
// 禁手检查: 黑棋不能走禁手位置
|
let killer_moves = killer.get(depth as usize);
|
||||||
if rules::is_forbidden(board, pos, color) {
|
let mut scored: Vec<(Position, f64)> = candidates
|
||||||
continue;
|
.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 let Ok(new_board) = board.place(pos, color) {
|
||||||
if new_board.check_win(pos) {
|
if new_board.check_win(pos) {
|
||||||
return Some(pos);
|
return (Some(pos), true);
|
||||||
}
|
}
|
||||||
let score = -self.negamax(
|
let score = -self.negamax(
|
||||||
&new_board,
|
&new_board,
|
||||||
self.depth - 1,
|
depth - 1,
|
||||||
f64::NEG_INFINITY,
|
-beta,
|
||||||
f64::INFINITY,
|
-alpha,
|
||||||
color.opponent(),
|
color.opponent(),
|
||||||
|
tt,
|
||||||
|
killer,
|
||||||
|
start,
|
||||||
|
time_limit,
|
||||||
);
|
);
|
||||||
if score > best_score {
|
if score > best_score {
|
||||||
best_score = score;
|
best_score = score;
|
||||||
best_pos = Some(pos);
|
best_pos = Some(pos);
|
||||||
}
|
}
|
||||||
|
if score > alpha {
|
||||||
|
alpha = score;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
best_pos
|
(best_pos, completed)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
impl AlphaBetaAi {
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn negamax(&self, board: &Board, depth: usize, mut alpha: f64, beta: f64, color: Color) -> f64 {
|
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 {
|
if depth == 0 {
|
||||||
return evaluate_board(board, color);
|
return evaluate_board(board, color);
|
||||||
}
|
}
|
||||||
@@ -64,41 +207,87 @@ impl AlphaBetaAi {
|
|||||||
return evaluate_board(board, color);
|
return evaluate_board(board, color);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 启发式排序:先评估每步棋,优先搜索高分走法 (跳过禁手)
|
// 启发式排序
|
||||||
|
let killer_moves = killer.get(depth as usize);
|
||||||
let mut scored: Vec<(Position, f64)> = candidates
|
let mut scored: Vec<(Position, f64)> = candidates
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|&pos| !rules::is_forbidden(board, pos, color))
|
.filter(|&p| !rules::is_forbidden(board, p, color))
|
||||||
.filter_map(|pos| {
|
.filter_map(|p| {
|
||||||
board.place(pos, color).ok().map(|b| {
|
board.place(p, color).ok().map(|b| {
|
||||||
if b.check_win(pos) {
|
if b.check_win(p) {
|
||||||
(pos, f64::INFINITY)
|
(p, f64::INFINITY)
|
||||||
} else {
|
} else {
|
||||||
let s = evaluate_board(&b, color);
|
(p, evaluate_board(&b, color))
|
||||||
(pos, s)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.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 max_val = f64::NEG_INFINITY;
|
||||||
|
let mut best_move = None;
|
||||||
|
|
||||||
for (pos, _) in scored {
|
for (pos, _) in scored {
|
||||||
|
if start.elapsed() >= time_limit {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
if let Ok(new_board) = board.place(pos, color) {
|
if let Ok(new_board) = board.place(pos, color) {
|
||||||
if new_board.check_win(pos) {
|
if new_board.check_win(pos) {
|
||||||
|
tt.store(
|
||||||
|
hash,
|
||||||
|
depth as u8,
|
||||||
|
f64::INFINITY as i32,
|
||||||
|
BoundType::Exact,
|
||||||
|
Some(pos),
|
||||||
|
);
|
||||||
return f64::INFINITY;
|
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 {
|
if val > max_val {
|
||||||
max_val = val;
|
max_val = val;
|
||||||
|
best_move = Some(pos);
|
||||||
}
|
}
|
||||||
if val > alpha {
|
if val > alpha {
|
||||||
alpha = val;
|
alpha = val;
|
||||||
}
|
}
|
||||||
if alpha >= beta {
|
if alpha >= beta {
|
||||||
|
killer.record(depth as usize, pos);
|
||||||
break;
|
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
|
max_val
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,65 +295,25 @@ impl AlphaBetaAi {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::ai::AiEngine;
|
|
||||||
use crate::board::Board;
|
use crate::board::Board;
|
||||||
use crate::types::{Color, Position};
|
use crate::types::{Color, Position};
|
||||||
|
|
||||||
#[test]
|
#[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 board = Board::new(15);
|
||||||
let ai = AlphaBetaAi::new(1);
|
let ai = AlphaBetaAi::new(3);
|
||||||
let mv = ai.best_move(&board, Color::Black);
|
let mv = ai.best_move(&board, Color::Black);
|
||||||
assert!(mv.is_some());
|
assert!(mv.is_some());
|
||||||
let pos = mv.unwrap();
|
|
||||||
assert!(pos.x >= 6 && pos.x <= 8);
|
|
||||||
assert!(pos.y >= 6 && pos.y <= 8);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_ai_blocks_rush_four() {
|
fn test_ai_takes_winning_move() {
|
||||||
// 白棋活三 (一端被己方黑棋堵住, 只有一端开放)
|
|
||||||
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) 都是胜着
|
|
||||||
let board = Board::new(15);
|
let board = Board::new(15);
|
||||||
let mut board = board;
|
let mut board = board;
|
||||||
board = board.place(Position::new(7, 3), Color::Black).unwrap();
|
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();
|
let mv = ai.best_move(&board, Color::Black).unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
(mv.x == 7 && mv.y == 2) || (mv.x == 7 && mv.y == 7),
|
(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.x,
|
||||||
mv.y
|
mv.y
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
/// 棋盘主体 — 不可变风格, place/undo 返回新 Board
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
@@ -7,6 +7,7 @@ pub struct Board {
|
|||||||
cells: [[CellState; MAX_BOARD_SIZE]; MAX_BOARD_SIZE],
|
cells: [[CellState; MAX_BOARD_SIZE]; MAX_BOARD_SIZE],
|
||||||
history: Vec<Move>,
|
history: Vec<Move>,
|
||||||
current_turn: u32,
|
current_turn: u32,
|
||||||
|
pub zobrist_hash: ZobristHash,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Board {
|
impl Board {
|
||||||
@@ -22,6 +23,7 @@ impl Board {
|
|||||||
cells: [[CellState::Empty; MAX_BOARD_SIZE]; MAX_BOARD_SIZE],
|
cells: [[CellState::Empty; MAX_BOARD_SIZE]; MAX_BOARD_SIZE],
|
||||||
history: Vec::new(),
|
history: Vec::new(),
|
||||||
current_turn: 0,
|
current_turn: 0,
|
||||||
|
zobrist_hash: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +35,11 @@ impl Board {
|
|||||||
self.cells[pos.x][pos.y]
|
self.cells[pos.x][pos.y]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 获取当前局面 Zobrist 哈希
|
||||||
|
pub fn hash(&self) -> ZobristHash {
|
||||||
|
self.zobrist_hash
|
||||||
|
}
|
||||||
|
|
||||||
/// 落子 — 返回新 Board (不可变)
|
/// 落子 — 返回新 Board (不可变)
|
||||||
pub fn place(&self, pos: Position, color: Color) -> Result<Board, MoveError> {
|
pub fn place(&self, pos: Position, color: Color) -> Result<Board, MoveError> {
|
||||||
if pos.x >= self.size || pos.y >= self.size {
|
if pos.x >= self.size || pos.y >= self.size {
|
||||||
@@ -44,6 +51,9 @@ impl Board {
|
|||||||
|
|
||||||
let mut new_board = self.clone();
|
let mut new_board = self.clone();
|
||||||
new_board.cells[pos.x][pos.y] = CellState::Occupied(color);
|
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 {
|
new_board.history.push(Move {
|
||||||
position: pos,
|
position: pos,
|
||||||
color,
|
color,
|
||||||
@@ -104,6 +114,14 @@ impl Board {
|
|||||||
let mut new_board = self.clone();
|
let mut new_board = self.clone();
|
||||||
let last_move = new_board.history.pop().unwrap();
|
let last_move = new_board.history.pop().unwrap();
|
||||||
new_board.cells[last_move.position.x][last_move.position.y] = CellState::Empty;
|
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);
|
new_board.current_turn = self.current_turn.saturating_sub(1);
|
||||||
Ok(new_board)
|
Ok(new_board)
|
||||||
}
|
}
|
||||||
@@ -258,7 +276,7 @@ mod tests {
|
|||||||
let result = board.undo();
|
let result = board.undo();
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
match result {
|
match result {
|
||||||
Err(MoveError::NoHistory) => {},
|
Err(MoveError::NoHistory) => {}
|
||||||
other => panic!("expected NoHistory, got {:?}", other),
|
other => panic!("expected NoHistory, got {:?}", other),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -269,4 +287,31 @@ mod tests {
|
|||||||
let _new = board.place(Position::new(7, 7), Color::Black).unwrap();
|
let _new = board.place(Position::new(7, 7), Color::Black).unwrap();
|
||||||
assert_eq!(board.get(Position::new(7, 7)), CellState::Empty);
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+348
-35
@@ -1,61 +1,374 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// 游戏网络消息
|
/// 网络传输的游戏消息(bincode 序列化)
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub enum GameMessage {
|
pub enum NetMessage {
|
||||||
Move { x: usize, y: usize, turn: u32 },
|
Move { x: usize, y: usize, turn: u32 },
|
||||||
Undo { steps: u32 },
|
Undo { steps: u32 },
|
||||||
Resign,
|
Resign,
|
||||||
Chat(String),
|
|
||||||
Heartbeat,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 网络连接角色
|
impl NetMessage {
|
||||||
|
pub fn to_bytes(&self) -> Vec<u8> {
|
||||||
|
bincode::serialize(self).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_bytes(data: &[u8]) -> Option<Self> {
|
||||||
|
bincode::deserialize(data).ok()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// commands 层 → 网络线程
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum NetworkCmd {
|
||||||
|
SendMove { x: usize, y: usize, turn: u32 },
|
||||||
|
SendUndo { steps: u32 },
|
||||||
|
SendResign,
|
||||||
|
Shutdown,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 网络线程 → commands 层
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum NetworkEvent {
|
||||||
|
RemoteMove { x: usize, y: usize },
|
||||||
|
RemoteUndo { steps: u32 },
|
||||||
|
RemoteResign,
|
||||||
|
ClientConnected,
|
||||||
|
ClientDisconnected,
|
||||||
|
Error(String),
|
||||||
|
Listening(u16),
|
||||||
|
Connected,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 网络角色
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum NetworkRole {
|
pub enum NetworkRole {
|
||||||
Server,
|
Server,
|
||||||
Client,
|
Client,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 网络会话配置
|
/// 网络循环句柄(在独立线程中运行)
|
||||||
#[derive(Debug, Clone)]
|
pub struct NetworkLoop {
|
||||||
pub struct NetworkConfig {
|
role: NetworkRole,
|
||||||
pub role: NetworkRole,
|
running: bool,
|
||||||
pub bind_port: u16,
|
cmd_rx: std::sync::mpsc::Receiver<NetworkCmd>,
|
||||||
pub remote_addr: String,
|
event_tx: std::sync::mpsc::Sender<NetworkEvent>,
|
||||||
pub remote_port: u16,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 网络会话状态
|
impl NetworkLoop {
|
||||||
#[derive(Debug, Clone)]
|
/// 创建 Server 端
|
||||||
pub struct NetworkSession {
|
pub fn new_server(
|
||||||
pub role: NetworkRole,
|
cmd_rx: std::sync::mpsc::Receiver<NetworkCmd>,
|
||||||
pub is_connected: bool,
|
event_tx: std::sync::mpsc::Sender<NetworkEvent>,
|
||||||
pub config: NetworkConfig,
|
) -> Self {
|
||||||
pending_messages: Vec<GameMessage>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl NetworkSession {
|
|
||||||
pub fn new(config: NetworkConfig) -> Self {
|
|
||||||
Self {
|
Self {
|
||||||
role: config.role,
|
role: NetworkRole::Server,
|
||||||
is_connected: false,
|
running: false,
|
||||||
config,
|
cmd_rx,
|
||||||
pending_messages: Vec::new(),
|
event_tx,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 发送消息 (实际 renet 集成在 gui 层处理)
|
/// 创建 Client 端
|
||||||
pub fn enqueue_message(&mut self, msg: GameMessage) {
|
pub fn new_client(
|
||||||
self.pending_messages.push(msg);
|
cmd_rx: std::sync::mpsc::Receiver<NetworkCmd>,
|
||||||
|
event_tx: std::sync::mpsc::Sender<NetworkEvent>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
role: NetworkRole::Client,
|
||||||
|
running: false,
|
||||||
|
cmd_rx,
|
||||||
|
event_tx,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 取出待发送的消息
|
/// 启动网络主循环(阻塞,在独立线程中调用)
|
||||||
pub fn drain_messages(&mut self) -> Vec<GameMessage> {
|
pub fn run(&mut self, server_addr: &str, protocol_id: u64) -> Result<(), String> {
|
||||||
std::mem::take(&mut self.pending_messages)
|
self.running = true;
|
||||||
|
match self.role {
|
||||||
|
NetworkRole::Server => self.run_server(protocol_id),
|
||||||
|
NetworkRole::Client => self.run_client(server_addr, protocol_id),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_connected(&mut self, connected: bool) {
|
fn run_server(&mut self, protocol_id: u64) -> Result<(), String> {
|
||||||
self.is_connected = connected;
|
use std::net::UdpSocket;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
let socket = UdpSocket::bind("0.0.0.0:0").map_err(|e| format!("Server 绑定失败: {}", e))?;
|
||||||
|
let local_addr = socket.local_addr().map_err(|e| e.to_string())?;
|
||||||
|
let local_port = local_addr.port();
|
||||||
|
let _ = self.event_tx.send(NetworkEvent::Listening(local_port));
|
||||||
|
|
||||||
|
let connection_config =
|
||||||
|
renet2::ConnectionConfig::from_shared_channels(vec![renet2::ChannelConfig {
|
||||||
|
channel_id: 0,
|
||||||
|
max_memory_usage_bytes: 5 * 1024 * 1024,
|
||||||
|
send_type: renet2::SendType::ReliableOrdered {
|
||||||
|
resend_time: Duration::from_millis(300),
|
||||||
|
},
|
||||||
|
}]);
|
||||||
|
let mut server = renet2::RenetServer::new(connection_config);
|
||||||
|
|
||||||
|
let current_time = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default();
|
||||||
|
let server_config = renet2_netcode::ServerSetupConfig {
|
||||||
|
current_time,
|
||||||
|
max_clients: 1,
|
||||||
|
protocol_id,
|
||||||
|
socket_addresses: vec![vec![local_addr]],
|
||||||
|
authentication: renet2_netcode::ServerAuthentication::Unsecure,
|
||||||
|
};
|
||||||
|
let native_socket = renet2_netcode::NativeSocket::new(socket)
|
||||||
|
.map_err(|e| format!("创建 NativeSocket 失败: {}", e))?;
|
||||||
|
let mut transport =
|
||||||
|
renet2_netcode::NetcodeServerTransport::new(server_config, native_socket)
|
||||||
|
.map_err(|e| format!("创建传输层失败: {}", e))?;
|
||||||
|
|
||||||
|
let tick = Duration::from_millis(16);
|
||||||
|
|
||||||
|
while self.running {
|
||||||
|
let now = Instant::now();
|
||||||
|
|
||||||
|
// 处理 commands 层发来的指令
|
||||||
|
while let Ok(cmd) = self.cmd_rx.try_recv() {
|
||||||
|
match cmd {
|
||||||
|
NetworkCmd::SendMove { x, y, turn } => {
|
||||||
|
let msg = NetMessage::Move { x, y, turn };
|
||||||
|
for cid in server.clients_id() {
|
||||||
|
server.send_message(cid, 0u8, msg.to_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NetworkCmd::SendUndo { steps } => {
|
||||||
|
let msg = NetMessage::Undo { steps };
|
||||||
|
for cid in server.clients_id() {
|
||||||
|
server.send_message(cid, 0u8, msg.to_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NetworkCmd::SendResign => {
|
||||||
|
for cid in server.clients_id() {
|
||||||
|
server.send_message(cid, 0u8, NetMessage::Resign.to_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NetworkCmd::Shutdown => {
|
||||||
|
self.running = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server.update(tick);
|
||||||
|
transport
|
||||||
|
.update(tick, &mut server)
|
||||||
|
.map_err(|e| format!("传输层更新失败: {e:?}"))?;
|
||||||
|
|
||||||
|
// 接收客户端消息
|
||||||
|
for cid in server.clients_id() {
|
||||||
|
while let Some(data) = server.receive_message(cid, 0u8) {
|
||||||
|
if let Some(msg) = NetMessage::from_bytes(&data) {
|
||||||
|
match msg {
|
||||||
|
NetMessage::Move { x, y, .. } => {
|
||||||
|
let _ = self.event_tx.send(NetworkEvent::RemoteMove { x, y });
|
||||||
|
}
|
||||||
|
NetMessage::Undo { steps } => {
|
||||||
|
let _ = self.event_tx.send(NetworkEvent::RemoteUndo { steps });
|
||||||
|
}
|
||||||
|
NetMessage::Resign => {
|
||||||
|
let _ = self.event_tx.send(NetworkEvent::RemoteResign);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理连接事件
|
||||||
|
while let Some(event) = server.get_event() {
|
||||||
|
match event {
|
||||||
|
renet2::ServerEvent::ClientConnected { .. } => {
|
||||||
|
let _ = self.event_tx.send(NetworkEvent::ClientConnected);
|
||||||
|
}
|
||||||
|
renet2::ServerEvent::ClientDisconnected { .. } => {
|
||||||
|
let _ = self.event_tx.send(NetworkEvent::ClientDisconnected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
transport.send_packets(&mut server);
|
||||||
|
|
||||||
|
let elapsed = now.elapsed();
|
||||||
|
if elapsed < tick {
|
||||||
|
std::thread::sleep(tick - elapsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_client(&mut self, server_addr: &str, protocol_id: u64) -> Result<(), String> {
|
||||||
|
use std::net::{SocketAddr, UdpSocket};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
let server_addr: SocketAddr = server_addr
|
||||||
|
.parse()
|
||||||
|
.map_err(|e| format!("地址解析失败: {}", e))?;
|
||||||
|
let socket = UdpSocket::bind("0.0.0.0:0").map_err(|e| format!("Client 绑定失败: {}", e))?;
|
||||||
|
|
||||||
|
let connection_config =
|
||||||
|
renet2::ConnectionConfig::from_shared_channels(vec![renet2::ChannelConfig {
|
||||||
|
channel_id: 0,
|
||||||
|
max_memory_usage_bytes: 5 * 1024 * 1024,
|
||||||
|
send_type: renet2::SendType::ReliableOrdered {
|
||||||
|
resend_time: Duration::from_millis(300),
|
||||||
|
},
|
||||||
|
}]);
|
||||||
|
let mut client = renet2::RenetClient::new(connection_config, false);
|
||||||
|
|
||||||
|
let current_time = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default();
|
||||||
|
let authentication = renet2_netcode::ClientAuthentication::Unsecure {
|
||||||
|
server_addr,
|
||||||
|
client_id: current_time.as_millis() as u64,
|
||||||
|
user_data: None,
|
||||||
|
protocol_id,
|
||||||
|
socket_id: 0,
|
||||||
|
};
|
||||||
|
let native_socket = renet2_netcode::NativeSocket::new(socket)
|
||||||
|
.map_err(|e| format!("创建 NativeSocket 失败: {}", e))?;
|
||||||
|
let mut transport = renet2_netcode::NetcodeClientTransport::new(
|
||||||
|
current_time,
|
||||||
|
authentication,
|
||||||
|
native_socket,
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("创建传输层失败: {}", e))?;
|
||||||
|
|
||||||
|
let tick = Duration::from_millis(16);
|
||||||
|
let mut was_connected = false;
|
||||||
|
|
||||||
|
while self.running {
|
||||||
|
let now = Instant::now();
|
||||||
|
|
||||||
|
while let Ok(cmd) = self.cmd_rx.try_recv() {
|
||||||
|
match cmd {
|
||||||
|
NetworkCmd::SendMove { x, y, turn } => {
|
||||||
|
let msg = NetMessage::Move { x, y, turn };
|
||||||
|
client.send_message(0u8, msg.to_bytes());
|
||||||
|
}
|
||||||
|
NetworkCmd::SendUndo { steps } => {
|
||||||
|
let msg = NetMessage::Undo { steps };
|
||||||
|
client.send_message(0u8, msg.to_bytes());
|
||||||
|
}
|
||||||
|
NetworkCmd::SendResign => {
|
||||||
|
client.send_message(0u8, NetMessage::Resign.to_bytes());
|
||||||
|
}
|
||||||
|
NetworkCmd::Shutdown => {
|
||||||
|
self.running = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
client.update(tick);
|
||||||
|
transport
|
||||||
|
.update(tick, &mut client)
|
||||||
|
.map_err(|e| format!("传输层更新失败: {e:?}"))?;
|
||||||
|
|
||||||
|
if client.is_connected() && !was_connected {
|
||||||
|
was_connected = true;
|
||||||
|
let _ = self.event_tx.send(NetworkEvent::Connected);
|
||||||
|
}
|
||||||
|
if !client.is_connected() && was_connected {
|
||||||
|
was_connected = false;
|
||||||
|
let _ = self.event_tx.send(NetworkEvent::ClientDisconnected);
|
||||||
|
}
|
||||||
|
|
||||||
|
while let Some(data) = client.receive_message(0u8) {
|
||||||
|
if let Some(msg) = NetMessage::from_bytes(&data) {
|
||||||
|
match msg {
|
||||||
|
NetMessage::Move { x, y, .. } => {
|
||||||
|
let _ = self.event_tx.send(NetworkEvent::RemoteMove { x, y });
|
||||||
|
}
|
||||||
|
NetMessage::Undo { steps } => {
|
||||||
|
let _ = self.event_tx.send(NetworkEvent::RemoteUndo { steps });
|
||||||
|
}
|
||||||
|
NetMessage::Resign => {
|
||||||
|
let _ = self.event_tx.send(NetworkEvent::RemoteResign);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
transport
|
||||||
|
.send_packets(&mut client)
|
||||||
|
.map_err(|e| format!("发送数据包失败: {e}"))?;
|
||||||
|
|
||||||
|
let elapsed = now.elapsed();
|
||||||
|
if elapsed < tick {
|
||||||
|
std::thread::sleep(tick - elapsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_net_message_move_roundtrip() {
|
||||||
|
let msg = NetMessage::Move {
|
||||||
|
x: 7,
|
||||||
|
y: 7,
|
||||||
|
turn: 0,
|
||||||
|
};
|
||||||
|
let bytes = msg.to_bytes();
|
||||||
|
let decoded = NetMessage::from_bytes(&bytes).unwrap();
|
||||||
|
match decoded {
|
||||||
|
NetMessage::Move { x, y, turn } => {
|
||||||
|
assert_eq!(x, 7);
|
||||||
|
assert_eq!(y, 7);
|
||||||
|
assert_eq!(turn, 0);
|
||||||
|
}
|
||||||
|
_ => panic!("wrong variant"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_net_message_undo_roundtrip() {
|
||||||
|
let msg = NetMessage::Undo { steps: 1 };
|
||||||
|
let bytes = msg.to_bytes();
|
||||||
|
let decoded = NetMessage::from_bytes(&bytes).unwrap();
|
||||||
|
assert!(matches!(decoded, NetMessage::Undo { steps: 1 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_net_message_resign_roundtrip() {
|
||||||
|
let msg = NetMessage::Resign;
|
||||||
|
let bytes = msg.to_bytes();
|
||||||
|
let decoded = NetMessage::from_bytes(&bytes).unwrap();
|
||||||
|
assert!(matches!(decoded, NetMessage::Resign));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_network_cmd_channel() {
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
tx.send(NetworkCmd::SendMove {
|
||||||
|
x: 7,
|
||||||
|
y: 7,
|
||||||
|
turn: 0,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
tx.send(NetworkCmd::Shutdown).unwrap();
|
||||||
|
|
||||||
|
let mut received = Vec::new();
|
||||||
|
while let Ok(cmd) = rx.try_recv() {
|
||||||
|
match cmd {
|
||||||
|
NetworkCmd::Shutdown => break,
|
||||||
|
NetworkCmd::SendMove { x, y, turn } => received.push((x, y, turn)),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(received, vec![(7, 7, 0)]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -183,6 +183,10 @@ mod tests {
|
|||||||
// ISO 8601 格式: YYYY-MM-DDTHH:MM:SSZ
|
// ISO 8601 格式: YYYY-MM-DDTHH:MM:SSZ
|
||||||
assert!(record.date.contains('T'), "date should contain T separator");
|
assert!(record.date.contains('T'), "date should contain T separator");
|
||||||
assert!(record.date.ends_with('Z'), "date should end with Z");
|
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"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,6 +122,8 @@ pub struct GameConfig {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub remote_address: String,
|
pub remote_address: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub host_port: u16,
|
||||||
|
#[serde(default)]
|
||||||
pub use_llm: bool,
|
pub use_llm: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub llm_endpoint: String,
|
pub llm_endpoint: String,
|
||||||
@@ -142,6 +144,7 @@ impl Default for GameConfig {
|
|||||||
player_color: Color::Black,
|
player_color: Color::Black,
|
||||||
is_server: false,
|
is_server: false,
|
||||||
remote_address: String::new(),
|
remote_address: String::new(),
|
||||||
|
host_port: 0,
|
||||||
use_llm: false,
|
use_llm: false,
|
||||||
llm_endpoint: String::new(),
|
llm_endpoint: String::new(),
|
||||||
llm_api_key: String::new(),
|
llm_api_key: String::new(),
|
||||||
@@ -149,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
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)
|
||||||
|
|
||||||
|
- 多线程并行搜索(收益有限,复杂度高)
|
||||||
|
- 蒙特卡洛树搜索(五子棋不适合)
|
||||||
|
- 神经网络评估(太重)
|
||||||
|
- 在线开局库更新
|
||||||
|
- 残局库
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
# Gobang 网络对战设计文档
|
||||||
|
|
||||||
|
> 状态: 已确认 | 日期: 2026-05-31
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
为 Gobang v2.0 实现基于 renet 的 P2P 网络对战,两人通过 IP:端口直连对弈。
|
||||||
|
|
||||||
|
## 架构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ React 前端 ───────────────────────────────────────────┐
|
||||||
|
│ OnlineSetup GameView │
|
||||||
|
│ 创建房间/加入房间 连接状态指示 │
|
||||||
|
│ listen("remote-move") │
|
||||||
|
└─────────────────────┬───────────────────────────────────┘
|
||||||
|
│ invoke / listen
|
||||||
|
┌─ Tauri IPC ─────────┼───────────────────────────────────┐
|
||||||
|
│ commands.rs │ │
|
||||||
|
│ host_game(port) │ 对手落子 → │
|
||||||
|
│ join_game(ip,port) │ app.emit("remote-move") │
|
||||||
|
│ send_move(x,y) │ │
|
||||||
|
│ send_undo() │ AppState.network_tx │
|
||||||
|
│ send_resign() │ channel 发送端 │
|
||||||
|
└─────────┬────────────┴──────────────────────────────────┘
|
||||||
|
│ mpsc::channel (NetworkCmd / NetworkEvent)
|
||||||
|
┌─ 网络线程 ──────────────────────────────────────────────┐
|
||||||
|
│ NetworkLoop │
|
||||||
|
│ Server: RenetServer + NetcodeServerTransport │
|
||||||
|
│ Client: RenetClient + NetcodeClientTransport │
|
||||||
|
│ │
|
||||||
|
│ loop { update → recv → process → send_packets } │
|
||||||
|
│ 16ms 帧率,消息通过 channel 与 commands 层通信 │
|
||||||
|
└──────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 通信模型
|
||||||
|
|
||||||
|
renet 内置 Server/Client。主机运行 RenetServer,对手作为 RenetClient 连接。所有消息经主机转发。
|
||||||
|
|
||||||
|
### 消息协议
|
||||||
|
|
||||||
|
网络传输使用 serde JSON + renet ReliableOrdered 通道:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
enum NetMessage {
|
||||||
|
Move { x: usize, y: usize, turn: u32 },
|
||||||
|
Undo { steps: u32 },
|
||||||
|
Resign,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Channel 接口
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// commands → 网络线程
|
||||||
|
enum NetworkCmd {
|
||||||
|
SendMove { x: usize, y: usize, turn: u32 },
|
||||||
|
SendUndo { steps: u32 },
|
||||||
|
SendResign,
|
||||||
|
Shutdown,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 网络线程 → commands
|
||||||
|
enum NetworkEvent {
|
||||||
|
RemoteMove { x: usize, y: usize },
|
||||||
|
RemoteUndo { steps: u32 },
|
||||||
|
RemoteResign,
|
||||||
|
ClientConnected,
|
||||||
|
ClientDisconnected,
|
||||||
|
Error(String),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 连接流程
|
||||||
|
|
||||||
|
```
|
||||||
|
主机 (Server) 对手 (Client)
|
||||||
|
host_game(port)
|
||||||
|
绑定 UDP "0.0.0.0:{port}"
|
||||||
|
NetworkLoop::Server 启动
|
||||||
|
返回实际端口
|
||||||
|
emit("waiting") ───────────────────→ 对手 join_game(ip, port)
|
||||||
|
bind UDP "0.0.0.0:0"
|
||||||
|
NetworkLoop::Client 启动
|
||||||
|
connect to server ──→
|
||||||
|
← connected ─────────
|
||||||
|
收到 ClientConnected
|
||||||
|
emit("opponent-joined") ←──────────→ emit("connected")
|
||||||
|
|
||||||
|
游戏开始,黑方(主机)先手正常落子
|
||||||
|
place_piece → NetworkCmd::SendMove ─→ broadcast ─→ client recv
|
||||||
|
NetworkEvent::RemoteMove
|
||||||
|
invoke place_piece
|
||||||
|
```
|
||||||
|
|
||||||
|
## 生命周期
|
||||||
|
|
||||||
|
- 网络线程在 `new_game(Online)` 时 spawn
|
||||||
|
- 游戏结束或 AppState drop 时:发送 Shutdown → 线程退出 → join
|
||||||
|
- 对手断开:主机的 ClientDisconnected event → emit 对手获胜
|
||||||
|
|
||||||
|
## 前端改动
|
||||||
|
|
||||||
|
### OnlineSetup
|
||||||
|
- 创建房间:输入端口号(可选,默认随机),显示"我的地址: IP:端口"
|
||||||
|
- 加入房间:输入"IP:端口",连接
|
||||||
|
|
||||||
|
### GameView — 新增连接状态条
|
||||||
|
- 等待中:显示"等待对手加入... (你的地址: IP:端口)"
|
||||||
|
- 已连接:显示"已连接"
|
||||||
|
- 已断开:显示"对手断开连接" + 对手获胜
|
||||||
|
|
||||||
|
### BoardCanvas — 监听 remote-move
|
||||||
|
```typescript
|
||||||
|
useEffect(() => {
|
||||||
|
const unlisten = listen<{ x: number; y: number }>('remote-move', (event) => {
|
||||||
|
placePiece(event.payload.x, event.payload.y);
|
||||||
|
});
|
||||||
|
return () => { unlisten.then(fn => fn()); };
|
||||||
|
}, []);
|
||||||
|
```
|
||||||
|
|
||||||
|
### GameControls
|
||||||
|
- Online 模式:不显示悔棋(需双方同意,暂不做)
|
||||||
|
- 认输:调用 send_resign
|
||||||
|
|
||||||
|
## AppState 改动
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct AppState {
|
||||||
|
// ... 现有字段 ...
|
||||||
|
pub network_tx: Mutex<Option<Sender<NetworkCmd>>>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 依赖
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# core/Cargo.toml & gui/Cargo.toml
|
||||||
|
renet = { version = "0.0.23", features = ["netcode"] }
|
||||||
|
renet_netcode = "0.0.15"
|
||||||
|
serde_json = "1" # 已有
|
||||||
|
```
|
||||||
|
|
||||||
|
## 不做 (YAGNI)
|
||||||
|
|
||||||
|
- Chat 功能(NetMessage 保留类型但无 UI)
|
||||||
|
- NAT 穿透 / 中转服务器
|
||||||
|
- 断线重连
|
||||||
|
- 观战模式
|
||||||
|
- 悔棋双方确认(Online 模式直接禁悔棋)
|
||||||
|
|
||||||
|
## 测试策略
|
||||||
|
|
||||||
|
- Rust 单元测试:NetMessage serde 往返、NetworkCmd/Event channel 通信、NetworkLoop::new 创建
|
||||||
|
- 集成测试:renet 本地 client 模拟(server.new_local_client)
|
||||||
|
- 前端测试:OnlineSetup 组件渲染、状态文本切换
|
||||||
|
- 手动测试:双窗口(host + join localhost)
|
||||||
@@ -17,3 +17,4 @@ serde_json = "1"
|
|||||||
gobang-core = { path = "../core" }
|
gobang-core = { path = "../core" }
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
env_logger = "0.11"
|
env_logger = "0.11"
|
||||||
|
renet2 = "0.15"
|
||||||
|
|||||||
+130
-2
@@ -1,11 +1,13 @@
|
|||||||
use gobang_core::ai::search::AlphaBetaAi;
|
use gobang_core::ai::search::AlphaBetaAi;
|
||||||
use gobang_core::ai::AiEngine;
|
use gobang_core::ai::AiEngine;
|
||||||
use gobang_core::llm::LlmAi;
|
|
||||||
use gobang_core::board::Board;
|
use gobang_core::board::Board;
|
||||||
|
use gobang_core::llm::LlmAi;
|
||||||
|
use gobang_core::network::{NetworkCmd, NetworkEvent, NetworkLoop};
|
||||||
use gobang_core::rules;
|
use gobang_core::rules;
|
||||||
use gobang_core::types::*;
|
use gobang_core::types::*;
|
||||||
|
use std::sync::mpsc;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use tauri::State;
|
use tauri::{Emitter, State};
|
||||||
|
|
||||||
/// 应用全局状态
|
/// 应用全局状态
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
@@ -15,6 +17,7 @@ pub struct AppState {
|
|||||||
pub ai_engine: Mutex<Option<std::sync::Arc<dyn AiEngine + Send + Sync>>>,
|
pub ai_engine: Mutex<Option<std::sync::Arc<dyn AiEngine + Send + Sync>>>,
|
||||||
pub current_color: Mutex<Color>,
|
pub current_color: Mutex<Color>,
|
||||||
pub game_over: Mutex<bool>,
|
pub game_over: Mutex<bool>,
|
||||||
|
pub network_tx: Mutex<Option<mpsc::Sender<NetworkCmd>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AppState {
|
impl Default for AppState {
|
||||||
@@ -26,12 +29,20 @@ impl Default for AppState {
|
|||||||
ai_engine: Mutex::new(None),
|
ai_engine: Mutex::new(None),
|
||||||
current_color: Mutex::new(Color::Black),
|
current_color: Mutex::new(Color::Black),
|
||||||
game_over: Mutex::new(true),
|
game_over: Mutex::new(true),
|
||||||
|
network_tx: Mutex::new(None),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn new_game(mode: GameMode, config: GameConfig, state: State<AppState>) -> Result<(), String> {
|
pub fn new_game(mode: GameMode, config: GameConfig, state: State<AppState>) -> Result<(), String> {
|
||||||
|
// 清理旧的网络连接
|
||||||
|
if let Ok(mut tx) = state.network_tx.lock() {
|
||||||
|
if let Some(tx) = tx.take() {
|
||||||
|
let _ = tx.send(NetworkCmd::Shutdown);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let is_vs_ai = mode == GameMode::VsAi;
|
let is_vs_ai = mode == GameMode::VsAi;
|
||||||
let board = Board::new(config.board_size);
|
let board = Board::new(config.board_size);
|
||||||
log::info!("新游戏: mode={:?}, board_size={}", mode, config.board_size);
|
log::info!("新游戏: mode={:?}, board_size={}", mode, config.board_size);
|
||||||
@@ -203,3 +214,120 @@ pub fn save_record(state: State<AppState>) -> Result<String, String> {
|
|||||||
let record = gobang_core::record::GameRecord::from_board(board, "玩家", "对手", None);
|
let record = gobang_core::record::GameRecord::from_board(board, "玩家", "对手", None);
|
||||||
serde_json::to_string_pretty(&record).map_err(|e| e.to_string())
|
serde_json::to_string_pretty(&record).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn host_game(port: u16, state: State<AppState>, app: tauri::AppHandle) -> Result<u16, String> {
|
||||||
|
let (cmd_tx, cmd_rx) = mpsc::channel();
|
||||||
|
let (event_tx, event_rx) = mpsc::channel();
|
||||||
|
|
||||||
|
let mut network = NetworkLoop::new_server(cmd_rx, event_tx);
|
||||||
|
let sock = std::net::UdpSocket::bind(format!("0.0.0.0:{}", port))
|
||||||
|
.map_err(|e| format!("绑定端口失败: {}", e))?;
|
||||||
|
let actual_port = sock.local_addr().map_err(|e| e.to_string())?.port();
|
||||||
|
drop(sock);
|
||||||
|
|
||||||
|
*state.network_tx.lock().map_err(|e| e.to_string())? = Some(cmd_tx);
|
||||||
|
|
||||||
|
let protocol_id: u64 = 7777;
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let _ = network.run("", protocol_id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// event 转发线程
|
||||||
|
let app_clone = app.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
for event in event_rx {
|
||||||
|
match event {
|
||||||
|
NetworkEvent::RemoteMove { x, y } => {
|
||||||
|
let _ = app_clone.emit("remote-move", serde_json::json!({ "x": x, "y": y }));
|
||||||
|
}
|
||||||
|
NetworkEvent::RemoteUndo { steps } => {
|
||||||
|
let _ = app_clone.emit("remote-undo", steps);
|
||||||
|
}
|
||||||
|
NetworkEvent::RemoteResign => {
|
||||||
|
let _ = app_clone.emit("remote-resign", ());
|
||||||
|
}
|
||||||
|
NetworkEvent::Connected | NetworkEvent::ClientConnected => {
|
||||||
|
let _ = app_clone.emit("connection-status", "connected");
|
||||||
|
}
|
||||||
|
NetworkEvent::ClientDisconnected => {
|
||||||
|
let _ = app_clone.emit("connection-status", "disconnected");
|
||||||
|
}
|
||||||
|
NetworkEvent::Error(msg) => {
|
||||||
|
let _ = app_clone.emit("network-error", msg);
|
||||||
|
}
|
||||||
|
NetworkEvent::Listening(port) => {
|
||||||
|
let _ = app_clone.emit("listening-port", port);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(actual_port)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn join_game(address: String, state: State<AppState>, app: tauri::AppHandle) -> Result<(), String> {
|
||||||
|
let (cmd_tx, cmd_rx) = mpsc::channel();
|
||||||
|
let (event_tx, event_rx) = mpsc::channel();
|
||||||
|
|
||||||
|
let mut network = NetworkLoop::new_client(cmd_rx, event_tx);
|
||||||
|
|
||||||
|
*state.network_tx.lock().map_err(|e| e.to_string())? = Some(cmd_tx);
|
||||||
|
|
||||||
|
let protocol_id: u64 = 7777;
|
||||||
|
let addr = address.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let _ = network.run(&addr, protocol_id);
|
||||||
|
});
|
||||||
|
|
||||||
|
let app_clone = app.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
for event in event_rx {
|
||||||
|
match event {
|
||||||
|
NetworkEvent::RemoteMove { x, y } => {
|
||||||
|
let _ = app_clone.emit("remote-move", serde_json::json!({ "x": x, "y": y }));
|
||||||
|
}
|
||||||
|
NetworkEvent::RemoteUndo { steps } => {
|
||||||
|
let _ = app_clone.emit("remote-undo", steps);
|
||||||
|
}
|
||||||
|
NetworkEvent::RemoteResign => {
|
||||||
|
let _ = app_clone.emit("remote-resign", ());
|
||||||
|
}
|
||||||
|
NetworkEvent::Connected | NetworkEvent::ClientConnected => {
|
||||||
|
let _ = app_clone.emit("connection-status", "connected");
|
||||||
|
}
|
||||||
|
NetworkEvent::ClientDisconnected => {
|
||||||
|
let _ = app_clone.emit("connection-status", "disconnected");
|
||||||
|
}
|
||||||
|
NetworkEvent::Error(msg) => {
|
||||||
|
let _ = app_clone.emit("network-error", msg);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn send_move(x: usize, y: usize, turn: u32, state: State<AppState>) -> Result<(), String> {
|
||||||
|
let tx = state.network_tx.lock().map_err(|e| e.to_string())?;
|
||||||
|
let tx = tx.as_ref().ok_or("未建立网络连接")?;
|
||||||
|
tx.send(NetworkCmd::SendMove { x, y, turn }).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn send_undo(steps: u32, state: State<AppState>) -> Result<(), String> {
|
||||||
|
let tx = state.network_tx.lock().map_err(|e| e.to_string())?;
|
||||||
|
let tx = tx.as_ref().ok_or("未建立网络连接")?;
|
||||||
|
tx.send(NetworkCmd::SendUndo { steps }).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn send_resign(state: State<AppState>) -> Result<(), String> {
|
||||||
|
let tx = state.network_tx.lock().map_err(|e| e.to_string())?;
|
||||||
|
let tx = tx.as_ref().ok_or("未建立网络连接")?;
|
||||||
|
tx.send(NetworkCmd::SendResign).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|||||||
+5
-1
@@ -12,11 +12,15 @@ pub fn run() {
|
|||||||
commands::new_game,
|
commands::new_game,
|
||||||
commands::place_piece,
|
commands::place_piece,
|
||||||
commands::undo,
|
commands::undo,
|
||||||
|
|
||||||
commands::ai_move,
|
commands::ai_move,
|
||||||
commands::get_game_state,
|
commands::get_game_state,
|
||||||
commands::resign,
|
commands::resign,
|
||||||
commands::save_record,
|
commands::save_record,
|
||||||
|
commands::host_game,
|
||||||
|
commands::join_game,
|
||||||
|
commands::send_move,
|
||||||
|
commands::send_undo,
|
||||||
|
commands::send_resign,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useCallback, useMemo } from 'react';
|
import { useEffect, useRef, useCallback, useMemo } from 'react';
|
||||||
|
import { listen } from '@tauri-apps/api/event';
|
||||||
import { useGameStore, buildReplayBoard } from '../../store/gameStore';
|
import { useGameStore, buildReplayBoard } from '../../store/gameStore';
|
||||||
import {
|
import {
|
||||||
computeBoardDimensions,
|
computeBoardDimensions,
|
||||||
@@ -59,6 +60,20 @@ export default function BoardCanvas() {
|
|||||||
return () => window.removeEventListener('resize', handleResize);
|
return () => window.removeEventListener('resize', handleResize);
|
||||||
}, [render]);
|
}, [render]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode !== 'Online') return;
|
||||||
|
let unlisten: (() => void) | undefined;
|
||||||
|
|
||||||
|
const setup = async () => {
|
||||||
|
unlisten = await listen<{ x: number; y: number }>('remote-move', (event) => {
|
||||||
|
placePiece(event.payload.x, event.payload.y);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
setup();
|
||||||
|
|
||||||
|
return () => { unlisten?.(); };
|
||||||
|
}, [mode, placePiece]);
|
||||||
|
|
||||||
const handleClick = useCallback(
|
const handleClick = useCallback(
|
||||||
(e: React.MouseEvent<HTMLCanvasElement>) => {
|
(e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||||
if (status !== 'playing') return;
|
if (status !== 'playing') return;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export default function GameControls({ onBackToMenu }: Props) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const undo = useGameStore((s) => s.undo);
|
const undo = useGameStore((s) => s.undo);
|
||||||
const status = useGameStore((s) => s.status);
|
const status = useGameStore((s) => s.status);
|
||||||
|
const mode = useGameStore((s) => s.mode);
|
||||||
const refreshBoard = useGameStore((s) => s.refreshBoard);
|
const refreshBoard = useGameStore((s) => s.refreshBoard);
|
||||||
|
|
||||||
const handleUndo = () => {
|
const handleUndo = () => {
|
||||||
@@ -38,7 +39,7 @@ export default function GameControls({ onBackToMenu }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="game-controls">
|
<div className="game-controls">
|
||||||
<button onClick={handleUndo} disabled={status === 'game_over'}>
|
<button onClick={handleUndo} disabled={status === 'game_over' || mode === 'Online'}>
|
||||||
{t('game.undo')}
|
{t('game.undo')}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleResign} disabled={status === 'game_over'}>
|
<button onClick={handleResign} disabled={status === 'game_over'}>
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { listen } from '@tauri-apps/api/event';
|
||||||
|
import { useGameStore } from '../../store/gameStore';
|
||||||
import BoardCanvas from '../board/BoardCanvas';
|
import BoardCanvas from '../board/BoardCanvas';
|
||||||
import GameInfo from './GameInfo';
|
import GameInfo from './GameInfo';
|
||||||
import GameControls from './GameControls';
|
import GameControls from './GameControls';
|
||||||
@@ -8,8 +12,33 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function GameView({ onBackToMenu }: Props) {
|
export default function GameView({ onBackToMenu }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const mode = useGameStore((s) => s.mode);
|
||||||
|
const [connStatus, setConnStatus] = useState<string>('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode !== 'Online') return;
|
||||||
|
let unlisten1: (() => void) | undefined;
|
||||||
|
let unlisten2: (() => void) | undefined;
|
||||||
|
|
||||||
|
const setup = async () => {
|
||||||
|
unlisten1 = await listen<string>('connection-status', (e) => setConnStatus(e.payload));
|
||||||
|
unlisten2 = await listen<number>('listening-port', (e) => setConnStatus('waiting:' + e.payload));
|
||||||
|
};
|
||||||
|
setup();
|
||||||
|
|
||||||
|
return () => { unlisten1?.(); unlisten2?.(); };
|
||||||
|
}, [mode]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="game-view">
|
<div className="game-view">
|
||||||
|
{mode === 'Online' && connStatus && (
|
||||||
|
<div style={{ fontSize: 14, opacity: 0.8 }}>
|
||||||
|
{connStatus.startsWith('waiting') ? '等待对手加入...' :
|
||||||
|
connStatus === 'connected' ? t('game.opponent_connected') :
|
||||||
|
connStatus === 'disconnected' ? t('game.opponent_disconnected') : ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<GameInfo />
|
<GameInfo />
|
||||||
<div className="board-container">
|
<div className="board-container">
|
||||||
<BoardCanvas />
|
<BoardCanvas />
|
||||||
|
|||||||
@@ -27,13 +27,7 @@ export default function MainMenu({ onGameStart, onReplayStart }: Props) {
|
|||||||
<div className="menu-buttons">
|
<div className="menu-buttons">
|
||||||
<button onClick={() => setView('local')}>{t('menu.local_game')}</button>
|
<button onClick={() => setView('local')}>{t('menu.local_game')}</button>
|
||||||
<button onClick={() => setView('ai')}>{t('menu.ai_game')}</button>
|
<button onClick={() => setView('ai')}>{t('menu.ai_game')}</button>
|
||||||
<button
|
<button onClick={() => setView('online')}>{t('menu.online_game')}</button>
|
||||||
onClick={() => setView('online')}
|
|
||||||
disabled
|
|
||||||
title={t('menu.online_game_disabled')}
|
|
||||||
>
|
|
||||||
{t('menu.online_game')} (开发中)
|
|
||||||
</button>
|
|
||||||
<button onClick={() => setView('replay')}>{t('menu.load_replay')}</button>
|
<button onClick={() => setView('replay')}>{t('menu.load_replay')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useGameStore } from '../../store/gameStore';
|
import { useGameStore } from '../../store/gameStore';
|
||||||
import { useState } from 'react';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { listen } from '@tauri-apps/api/event';
|
||||||
import { MIN_BOARD_SIZE, MAX_BOARD_SIZE } from '../../core/constants';
|
import { MIN_BOARD_SIZE, MAX_BOARD_SIZE } from '../../core/constants';
|
||||||
import type { GameConfig } from '../../core/types';
|
import type { GameConfig } from '../../core/types';
|
||||||
|
|
||||||
@@ -9,25 +11,67 @@ interface Props { onBack: () => void; onStart: () => void; }
|
|||||||
export default function OnlineSetup({ onBack, onStart }: Props) {
|
export default function OnlineSetup({ onBack, onStart }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const startGame = useGameStore((s) => s.startGame);
|
const startGame = useGameStore((s) => s.startGame);
|
||||||
const [ip, setIp] = useState('');
|
|
||||||
const [boardSize, setBoardSize] = useState(15);
|
const [boardSize, setBoardSize] = useState(15);
|
||||||
|
const [ip, setIp] = useState('');
|
||||||
const baseConfig: GameConfig = {
|
const [myAddress, setMyAddress] = useState('');
|
||||||
boardSize, useForbiddenRules: true, useTimer: false,
|
const [isHosting, setIsHosting] = useState(false);
|
||||||
timeLimitSecs: 60, aiDifficulty: 3, playerColor: 'Black', isServer: false,
|
|
||||||
remoteAddress: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleHost = async () => {
|
const handleHost = async () => {
|
||||||
await startGame('Online', { ...baseConfig, isServer: true });
|
try {
|
||||||
onStart();
|
const port: number = await invoke('host_game', { port: 0 });
|
||||||
|
setMyAddress(`127.0.0.1:${port}`);
|
||||||
|
setIsHosting(true);
|
||||||
|
|
||||||
|
const unlisten = await listen<string>('connection-status', async (event) => {
|
||||||
|
if (event.payload === 'connected') {
|
||||||
|
unlisten();
|
||||||
|
const config: GameConfig = {
|
||||||
|
boardSize, useForbiddenRules: true, useTimer: false,
|
||||||
|
timeLimitSecs: 60, aiDifficulty: 3, playerColor: 'Black',
|
||||||
|
isServer: true, remoteAddress: '', hostPort: port,
|
||||||
|
useLlm: false, llmEndpoint: '', llmApiKey: '', llmModel: '',
|
||||||
|
};
|
||||||
|
await startGame('Online', config);
|
||||||
|
onStart();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
alert('创建房间失败: ' + e);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleJoin = async () => {
|
const handleJoin = async () => {
|
||||||
await startGame('Online', { ...baseConfig, remoteAddress: ip });
|
try {
|
||||||
onStart();
|
const [_, portStr] = ip.split(':');
|
||||||
|
const port = parseInt(portStr) || 0;
|
||||||
|
const config: GameConfig = {
|
||||||
|
boardSize, useForbiddenRules: true, useTimer: false,
|
||||||
|
timeLimitSecs: 60, aiDifficulty: 3, playerColor: 'White',
|
||||||
|
isServer: false, remoteAddress: ip, hostPort: port,
|
||||||
|
useLlm: false, llmEndpoint: '', llmApiKey: '', llmModel: '',
|
||||||
|
};
|
||||||
|
await startGame('Online', config);
|
||||||
|
await invoke('join_game', { address: ip });
|
||||||
|
onStart();
|
||||||
|
} catch (e) {
|
||||||
|
alert('加入房间失败: ' + e);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (isHosting) {
|
||||||
|
return (
|
||||||
|
<div className="setup-panel">
|
||||||
|
<h2>{t('menu.online_game')}</h2>
|
||||||
|
<p style={{ fontSize: 18 }}>等待对手加入...</p>
|
||||||
|
<p style={{ fontSize: 24, fontFamily: 'monospace', background: '#F5DEB3', color: '#3C2415', padding: '8px 16px', borderRadius: 4 }}>
|
||||||
|
{myAddress}
|
||||||
|
</p>
|
||||||
|
<p style={{ fontSize: 14, opacity: 0.7 }}>将此地址发给对手</p>
|
||||||
|
<button onClick={onBack}>{t('common.back')}</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="setup-panel">
|
<div className="setup-panel">
|
||||||
<h2>{t('menu.online_game')}</h2>
|
<h2>{t('menu.online_game')}</h2>
|
||||||
@@ -35,13 +79,13 @@ export default function OnlineSetup({ onBack, onStart }: Props) {
|
|||||||
{t('settings.board_size')}:
|
{t('settings.board_size')}:
|
||||||
<select value={boardSize} onChange={(e) => setBoardSize(Number(e.target.value))}>
|
<select value={boardSize} onChange={(e) => setBoardSize(Number(e.target.value))}>
|
||||||
{Array.from({ length: MAX_BOARD_SIZE - MIN_BOARD_SIZE + 1 }, (_, i) => MIN_BOARD_SIZE + i).map((s) => (
|
{Array.from({ length: MAX_BOARD_SIZE - MIN_BOARD_SIZE + 1 }, (_, i) => MIN_BOARD_SIZE + i).map((s) => (
|
||||||
<option key={s} value={s}>{s}×{s}</option>
|
<option key={s} value={s}>{s}×{s}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<button onClick={handleHost}>{t('menu.host_room')}</button>
|
<button onClick={handleHost}>{t('menu.host_room')}</button>
|
||||||
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
|
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
|
||||||
<input value={ip} onChange={(e) => setIp(e.target.value)} placeholder={t('menu.ip_placeholder') as string} />
|
<input value={ip} onChange={(e) => setIp(e.target.value)} placeholder={t('menu.ip_placeholder')} />
|
||||||
<button onClick={handleJoin} disabled={!ip}>{t('menu.join_room')}</button>
|
<button onClick={handleJoin} disabled={!ip}>{t('menu.join_room')}</button>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onBack} style={{ marginTop: 12 }}>{t('common.back')}</button>
|
<button onClick={onBack} style={{ marginTop: 12 }}>{t('common.back')}</button>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export interface GameConfig {
|
|||||||
playerColor: Color;
|
playerColor: Color;
|
||||||
isServer: boolean;
|
isServer: boolean;
|
||||||
remoteAddress: string;
|
remoteAddress: string;
|
||||||
|
hostPort?: number;
|
||||||
useLlm?: boolean;
|
useLlm?: boolean;
|
||||||
llmEndpoint?: string;
|
llmEndpoint?: string;
|
||||||
llmApiKey?: string;
|
llmApiKey?: string;
|
||||||
|
|||||||
+3
-1
@@ -33,7 +33,9 @@
|
|||||||
"new_game": "New Game",
|
"new_game": "New Game",
|
||||||
"waiting_opponent": "Waiting for Opponent...",
|
"waiting_opponent": "Waiting for Opponent...",
|
||||||
"your_turn": "Your Turn",
|
"your_turn": "Your Turn",
|
||||||
"opponent_turn": "Opponent's Turn"
|
"opponent_turn": "Opponent's Turn",
|
||||||
|
"opponent_connected": "Opponent Connected",
|
||||||
|
"opponent_disconnected": "Opponent Disconnected"
|
||||||
},
|
},
|
||||||
"replay": {
|
"replay": {
|
||||||
"play": "Play",
|
"play": "Play",
|
||||||
|
|||||||
+3
-1
@@ -33,7 +33,9 @@
|
|||||||
"new_game": "新游戏",
|
"new_game": "新游戏",
|
||||||
"waiting_opponent": "等待对手加入...",
|
"waiting_opponent": "等待对手加入...",
|
||||||
"your_turn": "你的回合",
|
"your_turn": "你的回合",
|
||||||
"opponent_turn": "对手回合"
|
"opponent_turn": "对手回合",
|
||||||
|
"opponent_connected": "对手已连接",
|
||||||
|
"opponent_disconnected": "对手已断开"
|
||||||
},
|
},
|
||||||
"replay": {
|
"replay": {
|
||||||
"play": "播放",
|
"play": "播放",
|
||||||
|
|||||||
Reference in New Issue
Block a user