Skip to content

Commit 3530d48

Browse files
committed
implemented alpha beta pruning engine
1 parent b4da527 commit 3530d48

File tree

6 files changed

+279
-54
lines changed

6 files changed

+279
-54
lines changed

cmd/engine/alpha_beta_engine.go

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
package engine
2+
3+
import "github.com/RubikNube/GoInGo/cmd/game"
4+
5+
// AlphaBetaEngine implements Engine using an evaluation function and alpha-beta pruning.
6+
type AlphaBetaEngine struct{}
7+
8+
// Move in AlphaBetaEngine uses alpha-beta pruning to select the best move or pass if no beneficial move exists.
9+
func (e *AlphaBetaEngine) Move(board game.Board, player game.FieldState, ko *game.Point) *game.Point {
10+
bestScore := -1 << 30
11+
var bestMove *game.Point
12+
depth := 3 // Shallow for performance; increase for stronger play
13+
moveFound := false
14+
15+
for i := 0; i < 9; i++ {
16+
for j := 0; j < 9; j++ {
17+
if board[i][j] != game.Empty {
18+
continue
19+
}
20+
pt := game.Point{Row: i, Col: j}
21+
if ko != nil && pt.Row == ko.Row && pt.Col == ko.Col {
22+
continue
23+
}
24+
var nextBoard game.Board
25+
copy(nextBoard[:], board[:])
26+
nextBoard[pt.Row][pt.Col] = player
27+
opp := game.Black
28+
if player == game.Black {
29+
opp = game.White
30+
}
31+
for _, n := range game.Neighbors(pt) {
32+
if nextBoard[n.Row][n.Col] == opp {
33+
group, libs := game.Group(nextBoard, n)
34+
if len(libs) == 0 {
35+
for stonePt := range group {
36+
nextBoard[stonePt.Row][stonePt.Col] = game.Empty
37+
}
38+
}
39+
}
40+
}
41+
_, libs := game.Group(nextBoard, pt)
42+
if len(libs) == 0 {
43+
continue
44+
}
45+
score := -alphaBeta(nextBoard, opp, player, ko, depth-1, -1<<30, 1<<30)
46+
moveFound = true
47+
if score > bestScore {
48+
bestScore = score
49+
move := pt
50+
bestMove = &move
51+
}
52+
}
53+
}
54+
// Pass if no move found or if passing is as good or better than any move
55+
passScore := -alphaBeta(board, opponent(player), player, ko, depth-1, -1<<30, 1<<30)
56+
if !moveFound || passScore >= bestScore {
57+
return nil // pass
58+
}
59+
return bestMove
60+
}
61+
62+
// opponent returns the opposite FieldState (Black <-> White).
63+
func opponent(player game.FieldState) game.FieldState {
64+
if player == game.Black {
65+
return game.White
66+
}
67+
return game.Black
68+
}
69+
70+
// alphaBeta is a minimax search with alpha-beta pruning and pass support.
71+
func alphaBeta(board game.Board, player, opp game.FieldState, ko *game.Point, depth, alpha, beta int) int {
72+
if depth == 0 {
73+
return evaluate(board, player, opp)
74+
}
75+
foundMove := false
76+
for i := 0; i < 9; i++ {
77+
for j := 0; j < 9; j++ {
78+
if board[i][j] != game.Empty {
79+
continue
80+
}
81+
pt := game.Point{Row: i, Col: j}
82+
if ko != nil && pt.Row == ko.Row && pt.Col == ko.Col {
83+
continue
84+
}
85+
var nextBoard game.Board
86+
copy(nextBoard[:], board[:])
87+
nextBoard[pt.Row][pt.Col] = player
88+
for _, n := range game.Neighbors(pt) {
89+
if nextBoard[n.Row][n.Col] == opp {
90+
group, libs := game.Group(nextBoard, n)
91+
if len(libs) == 0 {
92+
for stonePt := range group {
93+
nextBoard[stonePt.Row][stonePt.Col] = game.Empty
94+
}
95+
}
96+
}
97+
}
98+
_, libs := game.Group(nextBoard, pt)
99+
if len(libs) == 0 {
100+
continue
101+
}
102+
foundMove = true
103+
score := -alphaBeta(nextBoard, opp, player, ko, depth-1, -beta, -alpha)
104+
if score > alpha {
105+
alpha = score
106+
}
107+
if alpha >= beta {
108+
return alpha
109+
}
110+
}
111+
}
112+
// Consider passing if no move found or passing is better
113+
passScore := -alphaBeta(board, opp, player, ko, depth-1, -beta, -alpha)
114+
if !foundMove || passScore > alpha {
115+
alpha = passScore
116+
}
117+
return alpha
118+
}
119+
120+
// evaluate is a sophisticated evaluation function considering liberties, groups, and captures.
121+
func evaluate(board game.Board, player, opp game.FieldState) int {
122+
playerStones, oppStones := 0, 0
123+
playerLibs, oppLibs := 0, 0
124+
playerGroups, oppGroups := 0, 0
125+
playerCapturable, oppCapturable := 0, 0
126+
127+
visited := make(map[game.Point]bool)
128+
for i := 0; i < 9; i++ {
129+
for j := 0; j < 9; j++ {
130+
pt := game.Point{Row: i, Col: j}
131+
if visited[pt] || board[i][j] == game.Empty {
132+
continue
133+
}
134+
group, libs := game.Group(board, pt)
135+
for stone := range group {
136+
visited[stone] = true
137+
}
138+
if board[i][j] == player {
139+
playerStones += len(group)
140+
playerLibs += len(libs)
141+
playerGroups++
142+
if len(libs) == 1 {
143+
playerCapturable += len(group)
144+
}
145+
} else if board[i][j] == opp {
146+
oppStones += len(group)
147+
oppLibs += len(libs)
148+
oppGroups++
149+
if len(libs) == 1 {
150+
oppCapturable += len(group)
151+
}
152+
}
153+
}
154+
}
155+
// Weighted sum: stones, liberties, groups, capturability
156+
return (playerStones-oppStones)*10 +
157+
(playerLibs-oppLibs)*2 +
158+
(oppCapturable-playerCapturable)*8 +
159+
(playerGroups - oppGroups)
160+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package engine
2+
3+
import (
4+
"testing"
5+
6+
"github.com/RubikNube/GoInGo/cmd/game"
7+
)
8+
9+
func TestAlphaBetaEngine_MoveReturnsLegalMove(t *testing.T) {
10+
board := game.Board{}
11+
engine := &AlphaBetaEngine{}
12+
player := game.Black
13+
var ko *game.Point
14+
15+
move := engine.Move(board, player, ko)
16+
if move == nil {
17+
t.Error("Expected a move, got nil")
18+
}
19+
if move != nil && (move.Row < 0 || move.Row > 8 || move.Col < 0 || move.Col > 8) {
20+
t.Errorf("Move out of bounds: %+v", move)
21+
}
22+
}
23+
24+
func TestAlphaBetaEngine_MoveReturnsNilWhenNoMoves(t *testing.T) {
25+
board := game.Board{}
26+
// Fill the board
27+
for i := range board {
28+
for j := range board[i] {
29+
board[i][j] = game.Black
30+
}
31+
}
32+
engine := &AlphaBetaEngine{}
33+
player := game.White
34+
var ko *game.Point
35+
36+
move := engine.Move(board, player, ko)
37+
if move != nil {
38+
t.Errorf("Expected nil (pass), got %+v", move)
39+
}
40+
}
41+
42+
func TestAlphaBetaEngine_PassIsOptimal(t *testing.T) {
43+
board := game.Board{}
44+
// Set up a board where any move would be suicide
45+
for i := range board {
46+
for j := range board[i] {
47+
board[i][j] = game.Black
48+
}
49+
}
50+
board[4][4] = game.Empty // Only one empty spot, but surrounded by Black
51+
engine := &AlphaBetaEngine{}
52+
player := game.White
53+
var ko *game.Point
54+
55+
move := engine.Move(board, player, ko)
56+
if move != nil {
57+
t.Errorf("Expected nil (pass) due to suicide, got %+v", move)
58+
}
59+
}

cmd/engine/engine.go

Lines changed: 0 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,6 @@
22
package engine
33

44
import (
5-
"math/rand"
6-
"time"
7-
85
"github.com/RubikNube/GoInGo/cmd/game"
96
)
107

@@ -13,53 +10,3 @@ type Engine interface {
1310
// Move returns the next move as a Point, or nil if passing.
1411
Move(board game.Board, player game.FieldState, ko *game.Point) *game.Point
1512
}
16-
17-
// RandomEngine implements Engine by picking a random legal move.
18-
type RandomEngine struct{}
19-
20-
func (e *RandomEngine) Move(board game.Board, player game.FieldState, ko *game.Point) *game.Point {
21-
empty := []game.Point{}
22-
for i := 0; i < 9; i++ {
23-
for j := 0; j < 9; j++ {
24-
if board[i][j] == game.Empty {
25-
empty = append(empty, game.Point{Row: i, Col: j})
26-
}
27-
}
28-
}
29-
rand.Seed(time.Now().UnixNano())
30-
perm := rand.Perm(len(empty))
31-
for _, idx := range perm {
32-
pt := empty[idx]
33-
// Ko rule
34-
if ko != nil && pt.Row == ko.Row && pt.Col == ko.Col {
35-
continue
36-
}
37-
var nextBoard game.Board
38-
copy(nextBoard[:], board[:])
39-
nextBoard[pt.Row][pt.Col] = player
40-
opp := game.Black
41-
if player == game.Black {
42-
opp = game.White
43-
}
44-
captured := []game.Point{}
45-
for _, n := range game.Neighbors(pt) {
46-
if nextBoard[n.Row][n.Col] == opp {
47-
group, libs := game.Group(nextBoard, n)
48-
if len(libs) == 0 {
49-
for stonePt := range group {
50-
nextBoard[stonePt.Row][stonePt.Col] = game.Empty
51-
captured = append(captured, stonePt)
52-
}
53-
}
54-
}
55-
}
56-
_, libs := game.Group(nextBoard, pt)
57-
if len(libs) == 0 {
58-
continue
59-
}
60-
// Legal move found
61-
return &pt
62-
}
63-
// No legal move, pass
64-
return nil
65-
}

cmd/engine/random_engine.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package engine
2+
3+
import (
4+
"math/rand"
5+
"time"
6+
7+
"github.com/RubikNube/GoInGo/cmd/game"
8+
)
9+
10+
// RandomEngine implements Engine by picking a random legal move.
11+
type RandomEngine struct{}
12+
13+
func (e *RandomEngine) Move(board game.Board, player game.FieldState, ko *game.Point) *game.Point {
14+
empty := []game.Point{}
15+
for i := 0; i < 9; i++ {
16+
for j := 0; j < 9; j++ {
17+
if board[i][j] == game.Empty {
18+
empty = append(empty, game.Point{Row: i, Col: j})
19+
}
20+
}
21+
}
22+
rand.Seed(time.Now().UnixNano())
23+
perm := rand.Perm(len(empty))
24+
for _, idx := range perm {
25+
pt := empty[idx]
26+
// Ko rule
27+
if ko != nil && pt.Row == ko.Row && pt.Col == ko.Col {
28+
continue
29+
}
30+
var nextBoard game.Board
31+
copy(nextBoard[:], board[:])
32+
nextBoard[pt.Row][pt.Col] = player
33+
opp := game.Black
34+
if player == game.Black {
35+
opp = game.White
36+
}
37+
captured := []game.Point{}
38+
for _, n := range game.Neighbors(pt) {
39+
if nextBoard[n.Row][n.Col] == opp {
40+
group, libs := game.Group(nextBoard, n)
41+
if len(libs) == 0 {
42+
for stonePt := range group {
43+
nextBoard[stonePt.Row][stonePt.Col] = game.Empty
44+
captured = append(captured, stonePt)
45+
}
46+
}
47+
}
48+
}
49+
_, libs := game.Group(nextBoard, pt)
50+
if len(libs) == 0 {
51+
continue
52+
}
53+
// Legal move found
54+
return &pt
55+
}
56+
// No legal move, pass
57+
return nil
58+
}

main.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -362,7 +362,8 @@ func main() {
362362
}
363363
keybindings = cfg.Keybindings
364364

365-
selectedEngine = &engine.RandomEngine{}
365+
// selectedEngine = &engine.RandomEngine{}
366+
selectedEngine = &engine.AlphaBetaEngine{}
366367
engineEnabled = true // Enable engine by default
367368

368369
defer g.Close()

0 commit comments

Comments
 (0)