20-add-negamax-eval-function #31
@@ -28,6 +28,9 @@ FILES = {c:i for i,c in enumerate("abcdefgh")}
|
||||
WHITE, BLACK, BOTH = 0, 1, 2
|
||||
P, N, B, R, Q, K, p, n, b, r, q, k = range(12)
|
||||
|
||||
PIECE_CH = ('P','N','B','R','Q','K','p','n','b','r','q','k')
|
||||
FILES_STR = "a b c d e f g h"
|
||||
|
||||
|
||||
"""
|
||||
Register C function bindings and interfaces in a dictionary.
|
||||
@@ -58,6 +61,9 @@ FFI_SPEC = {
|
||||
"perft": ((C.POINTER(Board), C.c_int), C.c_uint64),
|
||||
"load_fen": ((C.POINTER(Board), C.c_char_p), C.c_int),
|
||||
"gen_pseudo_moves": ((C.POINTER(Board), C.POINTER(Move), C.c_bool), C.c_int),
|
||||
"apply_move_on_copy": ((C.POINTER(Board), C.POINTER(Board), Move), C.c_bool),
|
||||
# AI Methods
|
||||
"ai_find_best_move_with_window": ((C.POINTER(Board), C.c_int, C.c_int, C.POINTER(Move)), C.c_int)
|
||||
}
|
||||
|
||||
|
||||
@@ -82,6 +88,28 @@ def draw_bb(mask, origin=None):
|
||||
print(lines, "\n")
|
||||
|
||||
|
||||
def print_board(board):
|
||||
"""
|
||||
Prints the current position using piece letters from board.pieces.
|
||||
highlight_sq: optional 0..63 square index to mark with 'O'.
|
||||
"""
|
||||
def piece_at(sq: int) -> str:
|
||||
for i, ch in enumerate(PIECE_CH):
|
||||
if (int(board.pieces[i]) >> sq) & 1:
|
||||
return ch
|
||||
return '.'
|
||||
|
||||
lines = []
|
||||
for r in range(7, -1, -1):
|
||||
row = []
|
||||
for f in range(8):
|
||||
sq = r * 8 + f
|
||||
row.append(piece_at(sq))
|
||||
lines.append(f"{r+1} " + " ".join(row))
|
||||
lines.append(" " + FILES_STR)
|
||||
print("\n" + "\n".join(lines) + "\n")
|
||||
|
||||
|
||||
def sq_to_coord(sq):
|
||||
return chr(ord('a') + (sq % 8)) + chr(ord('1') + (sq // 8))
|
||||
|
||||
@@ -131,6 +159,10 @@ class ChessFFI:
|
||||
return buf, n
|
||||
|
||||
|
||||
def apply_move_on_copy(self, board, out_board, move):
|
||||
return bool(self._c_apply_move_on_copy(C.byref(board), C.byref(out_board), move))
|
||||
|
||||
|
||||
def get_legal_moves(self, board, out):
|
||||
return self._c_get_legal_moves(board, out)
|
||||
|
||||
|
||||
4
engine/include/ai.h
Normal file
4
engine/include/ai.h
Normal file
@@ -0,0 +1,4 @@
|
||||
#include "bitboard.h"
|
||||
|
||||
int ai_find_best_move_with_window(struct Board *b, int depth, int window_cp, struct Move *best_out);
|
||||
int ai_play(struct Board *b, int depth);
|
||||
123
engine/src/ai/negamax.c
Normal file
123
engine/src/ai/negamax.c
Normal file
@@ -0,0 +1,123 @@
|
||||
#include <stdlib.h>
|
||||
#include <time.h>
|
||||
#include <limits.h>
|
||||
#include "ai.h"
|
||||
#include "bitboard.h"
|
||||
|
||||
#define INF 30000
|
||||
#define MATE 29000
|
||||
|
||||
// P N B R Q K
|
||||
int VAL[6] = {100, 300, 300, 500, 900, 0};
|
||||
|
||||
int popcount64(uint64_t bits) {
|
||||
int count = 0;
|
||||
while (bits) {
|
||||
// clear lowest set bit.
|
||||
bits &= (bits - 1);
|
||||
++count;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
static int eval(struct Board *board) {
|
||||
// White minus Black material (centipawns)
|
||||
// Ignore the King's material.
|
||||
int score = 0;
|
||||
score += VAL[0] * (popcount64(board->pieces[P]) - popcount64(board->pieces[p]));
|
||||
score += VAL[1] * (popcount64(board->pieces[N]) - popcount64(board->pieces[n]));
|
||||
score += VAL[2] * (popcount64(board->pieces[B]) - popcount64(board->pieces[b]));
|
||||
score += VAL[3] * (popcount64(board->pieces[R]) - popcount64(board->pieces[r]));
|
||||
score += VAL[4] * (popcount64(board->pieces[Q]) - popcount64(board->pieces[q]));
|
||||
|
||||
// Negamax convention: score from side-to-move POV
|
||||
return (board->side_to_move == WHITE) ? score : -score;
|
||||
}
|
||||
|
||||
static int search(struct Board *b, int depth, int alpha, int beta, int ply) {
|
||||
if (depth == 0) return eval(b);
|
||||
|
||||
struct Move moves[256];
|
||||
int n = get_legal_moves((struct Board*)b, moves);
|
||||
|
||||
if (n == 0) {
|
||||
// terminal: checkmate or stalemate
|
||||
// prefer quicker mates
|
||||
if (in_check(b, b->side_to_move)) {
|
||||
return -MATE + ply;
|
||||
}
|
||||
// Stalemate
|
||||
return 0;
|
||||
}
|
||||
|
||||
int best = -INF;
|
||||
for (int i = 0; i < n; ++i) {
|
||||
struct Board after;
|
||||
if (!apply_move_on_copy((struct Board*)b, &after, moves[i])) continue;
|
||||
|
||||
int score = -search(&after, depth - 1, -beta, -alpha, ply + 1);
|
||||
if (score > best) best = score;
|
||||
if (score > alpha) alpha = score;
|
||||
if (alpha >= beta) break; // cutoff
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
int rand_uniform(int upper_bound) {
|
||||
if (upper_bound <= 0) return 0;
|
||||
// Rejection sampling to reduce modulo bias
|
||||
int limit = RAND_MAX - (RAND_MAX % upper_bound);
|
||||
int r;
|
||||
do { r = rand(); } while (r > limit);
|
||||
return r % upper_bound;
|
||||
}
|
||||
|
||||
int ai_find_best_move_with_window(struct Board *board, int depth, int window_cp, struct Move *best_out) {
|
||||
enum { MAX_MOVES = 256 };
|
||||
struct Move moves[MAX_MOVES];
|
||||
int n = get_legal_moves(board, moves);
|
||||
if (n <= 0) return 0;
|
||||
|
||||
int scores[MAX_MOVES];
|
||||
int best = -INF;
|
||||
|
||||
for (int i = 0; i < n; ++i) {
|
||||
struct Board after;
|
||||
if (!apply_move_on_copy(board, &after, moves[i])) return 0;
|
||||
|
||||
// Root child: depth-1; alpha=-INF, beta=+INF; ply starts at 1
|
||||
int s = -search(&after, depth - 1, -INF, INF, 1);
|
||||
scores[i] = s;
|
||||
if (s > best) best = s;
|
||||
}
|
||||
|
||||
// collect candidates within window
|
||||
int cand_idx[MAX_MOVES], cand_n = 0;
|
||||
for (int i = 0; i < n; ++i)
|
||||
if (scores[i] >= best - window_cp)
|
||||
cand_idx[cand_n++] = i;
|
||||
|
||||
// fallback if window excludes all
|
||||
if (cand_n == 0) {
|
||||
int best_i = 0;
|
||||
for (int i = 1; i < n; ++i)
|
||||
if (scores[i] > scores[best_i]) best_i = i;
|
||||
*best_out = moves[best_i];
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Select a random candidate to prevent deterministic results.
|
||||
// I found that games were coming out with the same result each
|
||||
// time when the AI played itself.
|
||||
int pick = cand_idx[rand_uniform(cand_n)];
|
||||
*best_out = moves[pick];
|
||||
return 1;
|
||||
}
|
||||
|
||||
int ai_play(struct Board *b, int depth) {
|
||||
struct Move m;
|
||||
struct Board after;
|
||||
if (!apply_move_on_copy(b, &after, m)) return 0;
|
||||
*b = after;
|
||||
return 1;
|
||||
}
|
||||
54
makefile
54
makefile
@@ -2,46 +2,64 @@ CC := gcc
|
||||
CFLAGS := -O3 -fPIC -Wall -Wextra
|
||||
LDFLAGS := -shared
|
||||
|
||||
SRCDIR := engine/src
|
||||
INCDIR := engine/include
|
||||
# ---- paths ----
|
||||
SRCDIRS := engine/src engine/src/ai
|
||||
INCDIRS := engine/include
|
||||
BUILDDIR := build
|
||||
LIBNAME := libchess
|
||||
|
||||
# Exclude test.c from the shared lib build
|
||||
SRC := $(filter-out $(SRCDIR)/test.c,$(wildcard $(SRCDIR)/*.c))
|
||||
OBJ := $(patsubst $(SRCDIR)/%.c,$(BUILDDIR)/%.o,$(SRC))
|
||||
LIB := $(BUILDDIR)/$(LIBNAME).so
|
||||
|
||||
# ---- sources/objects ----
|
||||
EXCLUDE := engine/src/test.c
|
||||
SRCS := $(foreach d,$(SRCDIRS),$(wildcard $(d)/*.c))
|
||||
SRCS := $(filter-out $(EXCLUDE),$(SRCS))
|
||||
OBJS := $(patsubst %.c,$(BUILDDIR)/%.o,$(SRCS))
|
||||
|
||||
# ---- includes ----
|
||||
INCLUDES := $(addprefix -I,$(INCDIRS))
|
||||
|
||||
# ---- test executable (engine/src/test.c) ----
|
||||
TESTSRC := $(SRCDIR)/test.c
|
||||
TESTOBJ := $(BUILDDIR)/test.o
|
||||
TESTSRC := engine/src/test.c
|
||||
TESTOBJ := $(BUILDDIR)/engine/src/test.o
|
||||
TESTBIN := $(BUILDDIR)/print_board
|
||||
|
||||
.PHONY: all clean test test-exe run-c-test
|
||||
# ---- runner (python) ----
|
||||
PYTHON := python3
|
||||
RUN_SCRIPT := scripts/simulation.py
|
||||
ARGS ?=
|
||||
|
||||
.PHONY: all clean test test-exe run-c-test run-engine
|
||||
|
||||
all: $(LIB)
|
||||
|
||||
$(BUILDDIR):
|
||||
@mkdir -p $(BUILDDIR)
|
||||
# generic object rule: build/<path>/file.o from <path>/file.c
|
||||
$(BUILDDIR)/%.o: %.c
|
||||
@mkdir -p $(dir $@)
|
||||
$(CC) $(CFLAGS) $(INCLUDES) -c $< -o $@
|
||||
|
||||
$(BUILDDIR)/%.o: $(SRCDIR)/%.c | $(BUILDDIR)
|
||||
$(CC) $(CFLAGS) -I$(INCDIR) -c $< -o $@
|
||||
|
||||
$(LIB): $(OBJ) | $(BUILDDIR)
|
||||
$(LIB): $(OBJS)
|
||||
@mkdir -p $(dir $@)
|
||||
$(CC) $(LDFLAGS) -o $@ $^
|
||||
|
||||
# ---- test exe rules ----
|
||||
test-exe: $(TESTBIN)
|
||||
|
||||
$(TESTOBJ): $(TESTSRC) | $(BUILDDIR)
|
||||
$(CC) $(CFLAGS) -I$(INCDIR) -c $< -o $@
|
||||
$(TESTOBJ): $(TESTSRC)
|
||||
@mkdir -p $(dir $@)
|
||||
$(CC) $(CFLAGS) $(INCLUDES) -c $< -o $@
|
||||
|
||||
$(TESTBIN): $(TESTOBJ) $(LIB) | $(BUILDDIR)
|
||||
$(TESTBIN): $(TESTOBJ) $(LIB)
|
||||
$(CC) -O2 -o $@ $(TESTOBJ) -L$(BUILDDIR) -lchess -Wl,-rpath,'$$ORIGIN'
|
||||
|
||||
run-c-test: $(TESTBIN)
|
||||
LD_LIBRARY_PATH=$(BUILDDIR) $(TESTBIN) $(FEN)
|
||||
|
||||
# ---- run engine (Python) ----
|
||||
# Usage: make run-engine ARGS="--fen '...' --depth 5"
|
||||
run-engine: $(LIB)
|
||||
@echo "Running engine via $(RUN_SCRIPT)"
|
||||
LD_LIBRARY_PATH=$(BUILDDIR) $(PYTHON) $(RUN_SCRIPT) $(ARGS)
|
||||
|
||||
clean:
|
||||
@echo "Cleaning build and Python caches..."
|
||||
@rm -rf $(BUILDDIR)
|
||||
|
||||
62
scripts/evaluation.py
Normal file
62
scripts/evaluation.py
Normal file
@@ -0,0 +1,62 @@
|
||||
import random
|
||||
import ctypes as C
|
||||
from binding.python_c_ffi import ChessFFI
|
||||
from binding.python_c_ffi import Move
|
||||
|
||||
|
||||
class BaseEvaluation:
|
||||
def __init__(self, chess_ffi=None):
|
||||
if chess_ffi is None:
|
||||
chess_ffi = ChessFFI()
|
||||
self.chess_ffi = chess_ffi
|
||||
|
||||
|
||||
def get_best_move(self, board, legal_moves):
|
||||
pass
|
||||
|
||||
|
||||
"""
|
||||
We will use a random move evaluation as our base AI. This
|
||||
is expected to be the worst performing strategy. We can
|
||||
play our other AI evaluation methods against this one to
|
||||
confirm if our other strategies are at least better than
|
||||
a simplistic approach.
|
||||
"""
|
||||
class RandomEval(BaseEvaluation):
|
||||
def __init__(self, chess_ffi=None):
|
||||
super().__init__(chess_ffi)
|
||||
|
||||
|
||||
def get_best_move(self, board, legal_moves):
|
||||
return random.choice(legal_moves)
|
||||
|
||||
|
||||
class NegaMaxEval(BaseEvaluation):
|
||||
def __init__(self, depth=6, cp_window=20, chess_ffi=None):
|
||||
super().__init__(chess_ffi)
|
||||
self.depth = depth
|
||||
self.window = cp_window
|
||||
|
||||
|
||||
def _same(self, a, b):
|
||||
return (int(getattr(a, "from")) == int(getattr(b, "from"))
|
||||
and int(a.to) == int(b.to)
|
||||
and int(getattr(a, "promo", 0) or 0) == int(getattr(b, "promo", 0) or 0))
|
||||
|
||||
def get_best_move(self, board, legal_moves):
|
||||
if not legal_moves:
|
||||
raise RuntimeError("No legal moves")
|
||||
|
||||
best = Move()
|
||||
ok = self.chess_ffi._c_ai_find_best_move_with_window(
|
||||
board,
|
||||
self.depth,
|
||||
self.window,
|
||||
best
|
||||
)
|
||||
|
||||
if ok:
|
||||
for m in legal_moves:
|
||||
if self._same(m, best):
|
||||
return m
|
||||
return legal_moves[0]
|
||||
149
scripts/simulation.py
Normal file
149
scripts/simulation.py
Normal file
@@ -0,0 +1,149 @@
|
||||
from binding.python_c_ffi import Board
|
||||
from binding.python_c_ffi import Move
|
||||
from binding.python_c_ffi import ChessFFI
|
||||
from binding.python_c_ffi import WHITE
|
||||
from binding.python_c_ffi import print_board
|
||||
from binding.python_c_ffi import sq_to_coord
|
||||
from scripts.evaluation import RandomEval
|
||||
from scripts.evaluation import NegaMaxEval
|
||||
|
||||
|
||||
YMD_HM = "%Y-%m-%d-%H-%M"
|
||||
MAX_MOVES = 256
|
||||
DATA_PATH = "./data"
|
||||
|
||||
|
||||
class Engine:
|
||||
def __init__(self, strat_white=None, strat_black=None, max_plys=500):
|
||||
self.chess_ffi = ChessFFI()
|
||||
self.board = Board()
|
||||
self.max_plys = max_plys
|
||||
self.moves = []
|
||||
self._load_attack_cache()
|
||||
self._seed_engine()
|
||||
|
||||
random_strat = RandomEval(chess_ffi=self.chess_ffi)
|
||||
nega_strat = NegaMaxEval(chess_ffi=self.chess_ffi, depth=2, cp_window=30)
|
||||
if not strat_white:
|
||||
self.strat_white = random_strat
|
||||
else:
|
||||
self.strat_white = strat_white
|
||||
|
||||
if not strat_black:
|
||||
self.strat_black = random_strat
|
||||
else:
|
||||
self.strat_black = strat_black
|
||||
|
||||
|
||||
def run(self, fen):
|
||||
self.chess_ffi.load_fen(self.board, fen)
|
||||
self._clear_moves()
|
||||
plys = 0
|
||||
|
||||
while plys <= self.max_plys:
|
||||
print_board(self.board)
|
||||
|
||||
moves_buf = (Move * MAX_MOVES)()
|
||||
n_legal = self.chess_ffi.get_legal_moves(self.board, moves_buf)
|
||||
|
||||
game_over, ending = self._is_game_over(n_legal)
|
||||
if game_over:
|
||||
print(ending)
|
||||
break
|
||||
|
||||
legal = [moves_buf[i] for i in range(n_legal)]
|
||||
if self.board.side_to_move == WHITE:
|
||||
best_move = self.strat_white.get_best_move(self.board, legal)
|
||||
else:
|
||||
best_move = self.strat_black.get_best_move(self.board, legal)
|
||||
|
||||
|
||||
new_board = Board()
|
||||
if not self.chess_ffi.apply_move_on_copy(self.board, new_board, best_move):
|
||||
print("ERROR: apply_move_on_copy failed")
|
||||
break
|
||||
|
||||
self.board = new_board
|
||||
plys += 1
|
||||
|
||||
move = self.to_uci(best_move)
|
||||
self.moves.append(move)
|
||||
|
||||
if self.board.halfmove_clock >= 100:
|
||||
print("draw (50-move rule)")
|
||||
break
|
||||
|
||||
for move in self.moves:
|
||||
# print(move)
|
||||
pass
|
||||
|
||||
|
||||
print("done")
|
||||
print(f"winner: {self.side_tag(not self.board.side_to_move)}")
|
||||
print(len(self.moves))
|
||||
|
||||
|
||||
def side_tag(self, side):
|
||||
return 'w' if side == WHITE else 'b'
|
||||
|
||||
|
||||
def load_fen(self, fen):
|
||||
self.chess_ffi.load_fen(self.board, fen)
|
||||
|
||||
|
||||
def _clear_moves(self):
|
||||
self.moves = []
|
||||
|
||||
|
||||
def to_uci(self, move):
|
||||
fr = sq_to_coord(getattr(move, "from"))
|
||||
to = sq_to_coord(move.to)
|
||||
promo = getattr(move, "promo", 0) or ""
|
||||
|
||||
if promo:
|
||||
MAP = {
|
||||
1:"n",
|
||||
2:"b",
|
||||
3:"r",
|
||||
4:"q",
|
||||
"n":"n",
|
||||
"b":"b",
|
||||
"r":"r",
|
||||
"q":"q"
|
||||
}
|
||||
promo = MAP.get(promo, "").lower()
|
||||
return f"{fr}{to}{promo}"
|
||||
|
||||
|
||||
def _load_attack_cache(self):
|
||||
self.chess_ffi.init_attack_cache()
|
||||
|
||||
|
||||
def _seed_engine(self):
|
||||
import ctypes as C, time
|
||||
|
||||
seed = time.time_ns()
|
||||
s32 = int(seed) & 0xFFFFFFFF
|
||||
|
||||
libc = C.CDLL("libc.so.6") # on Ubuntu
|
||||
libc.srand.argtypes = (C.c_uint,)
|
||||
libc.srand.restype = None
|
||||
libc.srand(C.c_uint(s32))
|
||||
return s32
|
||||
|
||||
|
||||
def _is_game_over(self, legal_moves):
|
||||
if legal_moves == 0:
|
||||
if self.chess_ffi.in_check(self.board, self.board.side_to_move):
|
||||
ending = "checkmate"
|
||||
else:
|
||||
ending = "stalemate"
|
||||
return True, ending
|
||||
return False, None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
engine = Engine()
|
||||
|
||||
fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
|
||||
engine.run(fen)
|
||||
Reference in New Issue
Block a user